From 4502d6b5bc317e3554fde93eb8716646791c54cb Mon Sep 17 00:00:00 2001 From: chenchen Date: Wed, 10 Jun 2026 11:29:34 +0800 Subject: [PATCH] feat(preflight): read-only preflight checklist + execution summary (#39, #40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 GET /api/heicode/preflight?template_id=&binding_ids=1,2,3 —— agent 启动前的统一 确认层(#29 EPIC 的 #39 缺失项检测 + #40 可读执行摘要): - #39 缺失项检测:必需资源类别(git/sk/project_document/cloud_account)未绑定、budget (余额≤0)、agent_slot(在跑数达 tier 上限)。ready = missing 为空。 - #40 可读执行摘要:agent 角色、脱敏资源视图、高危操作、预算(剩余额度/quota_per_unit/ tier 上限/当前在跑数)、审批策略。 - 红线:resources 只暴露 type/provider/name/status/has_secret(布尔),绝不含 secret_ref/channelId/base_url/price。 - 高危操作固定 enum:production_deploy/db_write/cloud_resource_delete/ production_secret/large_budget,由已绑资源类型推导,均 requires_approval。 判定逻辑抽为纯函数 computePreflight,单测覆盖:全缺失、就绪、槽位满、高危 enum、 敏感字段不泄露(序列化断言)。复用既有 ResourceBinding/模板/GetUserMaxAgents/部署门禁 口径,不改部署/计费逻辑。文档补 §4.1。 #41(confirm + 审计 + 防篡改版本校验)作为后续 POST /preflight/confirm 实现。 Affects: Manager only(新增只读端点)。无 Client/Swarm 代码改动,无计费/审计 schema 改动。 Co-Authored-By: Claude Opus 4.8 --- .../integration/heicode-desktop-client-api.md | 41 +++ heicode/controller/agent_preflight.go | 237 ++++++++++++++++++ heicode/controller/agent_preflight_test.go | 96 +++++++ heicode/router/api-router.go | 2 + 4 files changed, 376 insertions(+) create mode 100644 heicode/controller/agent_preflight.go create mode 100644 heicode/controller/agent_preflight_test.go diff --git a/docs/integration/heicode-desktop-client-api.md b/docs/integration/heicode-desktop-client-api.md index 884b6d2f..de6eeedb 100644 --- a/docs/integration/heicode-desktop-client-api.md +++ b/docs/integration/heicode-desktop-client-api.md @@ -146,6 +146,47 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) ) --- +## 4.1 启动前预检 / 执行摘要 🟢(preflight) + +部署 agent 前,给用户看一份「执行摘要」:用哪些资源、还缺什么、有哪些高危操作、预算上限。用户**只确认摘要**,不必面对完整参数。 + +| 方法 | 路径 | 鉴权 | 说明 | +|---|---|---|---| +| GET | `/api/heicode/preflight?template_id=&binding_ids=1,2,3` | `UserOrV2DeviceAuth` | 返回缺失项 + 可读执行摘要 | + +- `binding_ids` 同部署入参(逗号分隔或重复 key,可空)。 + +```json +{ "success": true, "data": { + "template_id": "architect", + "agent_role": { "template_id":"architect", "name":"架构顾问", "model":"opus" }, + "resources": [ + { "binding_id":17, "type":"git", "provider":"github", "name":"my-repo", "status":"active", "has_secret":true } + ], + "invalid_bindings": [], + "missing": [ + { "kind":"sk", "reason":"未绑定SK 资源包" }, + { "kind":"budget", "reason":"账户可用额度不足,请充值或开通订阅" } + ], + "high_risk_ops": [ + { "op":"production_deploy", "label":"生产部署 / 代码改动", "requires_approval":true }, + { "op":"large_budget", "label":"大额预算消耗", "requires_approval":true } + ], + "budget": { "remaining_quota":1234567, "quota_per_unit":500000, "tier_max_agents":5, "current_agents":1 }, + "approval_policy": { "mode":"per_high_risk_op" }, + "ready": false +}} +``` + +- **`missing`**:必需类别(`git`/`sk`/`project_document`/`cloud_account`)未绑定、`budget`(余额≤0)、`agent_slot`(在跑数已达 tier 上限)。`ready=true` 当且仅当 `missing` 为空。 +- **`high_risk_ops`**:固定 enum —— `production_deploy` / `db_write` / `cloud_resource_delete` / `production_secret` / `large_budget`;由已绑资源类型推导,均 `requires_approval`。 +- **红线**:`resources` 只暴露 `type/provider/name/status/has_secret`(布尔),**绝不返回 `secret_ref`/`channelId`/`base_url`/价格**。 +- **`invalid_bindings`**:请求里无效 / 非本人 / 非 active 的绑定 id(不阻断,供前端提示)。 + +> confirm + 审计 + 防篡改版本校验(#41)将作为 `POST /api/heicode/preflight/confirm` 后续补充;当前 preflight 为只读。 + +--- + ## 5. 我的 Agent 🟢(已对接 AM,生产验证) > **2026-06-04 生产实测**:部署 / 列表 / 详情 / 状态 / 删除均真实可用。 diff --git a/heicode/controller/agent_preflight.go b/heicode/controller/agent_preflight.go new file mode 100644 index 00000000..c3080f7e --- /dev/null +++ b/heicode/controller/agent_preflight.go @@ -0,0 +1,237 @@ +package controller + +import ( + "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 配额未满 +} + +// 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, + } +} + +// 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 +} + +// 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 + } + tpl, ok := loadAgentTemplate(templateID) + if !ok { + agentError(c, "POLICY_REJECTED", "unknown template_id") + return + } + + // 解析请求的绑定 → 脱敏视图;无效/非本人/非 active 的归入 invalidBindings。 + bindingIDs := parsePreflightBindingIDs(c) + 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) != "", + }) + } + + // 用户剩余额度 + tier 上限 + 当前在跑 agent 数(与部署门禁同口径)。 + 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 + + summary := computePreflight(tpl, resources, invalid, remainingQuota, common.QuotaPerUnit, maxAgents, int(currentAgents)) + common.ApiSuccess(c, summary) +} diff --git a/heicode/controller/agent_preflight_test.go b/heicode/controller/agent_preflight_test.go new file mode 100644 index 00000000..f701864d --- /dev/null +++ b/heicode/controller/agent_preflight_test.go @@ -0,0 +1,96 @@ +package controller + +import ( + "encoding/json" + "testing" + + "github.com/heicode/manager/model" + "github.com/stretchr/testify/require" +) + +func tpl() model.AgentTemplate { + return model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus"} +} + +// #39: 全部必需类别缺失 + 预算不足 → missing 覆盖各项,ready=false。 +func TestComputePreflight_AllMissing(t *testing.T) { + s := computePreflight(tpl(), nil, nil, 0, 500000, 5, 0) + kinds := map[string]bool{} + for _, m := range s.Missing { + kinds[m.Kind] = true + } + require.True(t, kinds["git"]) + require.True(t, kinds["sk"]) + require.True(t, kinds["project_document"]) + require.True(t, kinds["cloud_account"]) + require.True(t, kinds["budget"], "余额为 0 应报 budget 缺失") + require.False(t, kinds["agent_slot"], "0/5 未满,不应报 agent_slot") + require.False(t, s.Ready) +} + +// #39: 全部齐备 + 有余额 + 槽位未满 → ready=true。 +func TestComputePreflight_Ready(t *testing.T) { + res := []preflightResource{ + {BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true}, + {BindingID: 2, Type: "sk", Provider: "custom", Name: "sk-pack", Status: "active", HasSecret: true}, + {BindingID: 3, Type: "project_document", Provider: "custom", Name: "doc", Status: "active"}, + {BindingID: 4, Type: "cloud_account", Provider: "azure", Name: "sub", Status: "active", HasSecret: true}, + } + s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 1) + require.Empty(t, s.Missing) + require.True(t, s.Ready) +} + +// #39: agent 槽位已满 → ready=false + agent_slot 缺失项。 +func TestComputePreflight_AgentSlotFull(t *testing.T) { + res := []preflightResource{ + {BindingID: 1, Type: "git"}, {BindingID: 2, Type: "sk"}, + {BindingID: 3, Type: "project_document"}, {BindingID: 4, Type: "cloud_account"}, + } + s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 5) + require.False(t, s.Ready) + found := false + for _, m := range s.Missing { + if m.Kind == "agent_slot" { + found = true + } + } + require.True(t, found) +} + +// #40: 高危操作只用固定 enum,并按已绑资源类型推导。 +func TestComputePreflight_HighRiskEnum(t *testing.T) { + res := []preflightResource{ + {BindingID: 1, Type: "git"}, + {BindingID: 2, Type: "database"}, + {BindingID: 3, Type: "cloud_account"}, + } + s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 0) + ops := map[string]bool{} + for _, h := range s.HighRiskOps { + require.Contains(t, highRiskOpLabels, h.Op, "high-risk op 必须是固定 enum") + require.True(t, h.RequiresApproval) + ops[h.Op] = true + } + require.True(t, ops[highRiskProductionDeploy]) // git + require.True(t, ops[highRiskDBWrite]) // database + require.True(t, ops[highRiskCloudDelete]) // cloud_account + require.True(t, ops[highRiskProductionSecret]) // cloud_account + require.True(t, ops[highRiskLargeBudget]) // 标准确认项 +} + +// #40 红线:resource 视图序列化后绝不含 secret_ref/channel_id/base_url/price。 +func TestComputePreflight_NoSensitiveFieldsLeaked(t *testing.T) { + res := []preflightResource{ + {BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true}, + } + s := computePreflight(tpl(), res, []int{99}, 1_000_000, 500000, 5, 0) + b, err := json.Marshal(s) + require.NoError(t, err) + out := string(b) + for _, banned := range []string{"secret_ref", "channel_id", "channelId", "base_url", "baseUrl", "price"} { + require.NotContains(t, out, banned, "执行摘要不得暴露敏感字段: "+banned) + } + require.Contains(t, out, "\"has_secret\":true") // 只暴露布尔 + require.Contains(t, out, "\"invalid_bindings\":[99]") +} diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index dc99a618..97f36412 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -535,6 +535,8 @@ func SetApiRouter(router *gin.Engine) { { heicodeAgentRoute.GET("/agent-templates", controller.HeicodeListAgentTemplates) heicodeAgentRoute.GET("/available-models", controller.HeicodeAvailableModels) + // Preflight / execution-summary (#39 缺失项检测 + #40 可读摘要). + heicodeAgentRoute.GET("/preflight", controller.HeicodePreflight) heicodeAgentRoute.POST("/agents", controller.HeicodeDeployAgent) heicodeAgentRoute.GET("/agents", controller.HeicodeListAgents) heicodeAgentRoute.GET("/agents/:deployment_id", controller.HeicodeGetAgent)