Author SHA1 Message Date
chenchenandClaude Opus 4.8 088e3dd5a2 fix(telemetry): 补遥测后台 zh 翻译,修复中英混杂
遥测页/侧栏多处 key(Client Telemetry/Device ID/Error category/Error code/
Apply/Aggregate/Stack hash/Distinct users + 描述)缺 zh 翻译,回退成英文,
与中文 UI 混杂。补入 zh.json(行级插入,保留 translation 包装,JSON 校验通过)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:34:51 +08:00
chenchen 992c322858 Merge remote-tracking branch 'origin/feat/admin-telemetry-views' into deploy/test-pr69-73 2026-06-11 19:19:36 +08:00
chenchen b36dc3fe28 Merge remote-tracking branch 'origin/feat/swarm-io-proxy-46' into deploy/test-pr69-73 2026-06-11 19:19:35 +08:00
chenchen 66947cd543 Merge remote-tracking branch 'origin/feat/swarm-model-key-injection-60' into deploy/test-pr69-73 2026-06-11 19:19:34 +08:00
chenchen f091089365 Merge remote-tracking branch 'origin/feat/swarm-subscription-gate' into deploy/test-pr69-73 2026-06-11 19:19:32 +08:00
chenchenandClaude Opus 4.8 e7239c24b6 feat(telemetry): 管理员遥测后台只读视图(列表 + 聚合)
客户端错误遥测(#24)此前只有 ingest + 留存清理,无后台查看。新增管理员只读视图
(列表 + 聚合两者),admin-gated —— device_id 可关联账号,仅管理员可读。

后端:
- model.ListTelemetryEvents(filter, startIdx, pageSize):分页列表(received_at desc),
  按 user_id/client_id/platform/app_version/error_category/error_code + 时间范围过滤。
- model.AggregateTelemetryEvents(filter, dimension, limit):按白名单维度 group by 计数 +
  COUNT(DISTINCT user_id),busiest first。维度白名单是 SQL 注入防线(列名只能来自 map)。
  跨库安全(仅 COUNT/COUNT(DISTINCT)/GROUP BY 固定列,无 DB 特有日期函数)。
- 查询走 LOG_DB(与 ingest 同库);遥测仍隔离于计费、载荷无用户内容。
- controller AdminListTelemetryEvents / AdminAggregateTelemetryEvents;路由
  GET /api/telemetry/{events,aggregate},middleware.AdminAuth() 守门。

前端(web/default):
- features/telemetry:api + types + 页面(过滤条 + 聚合面板含维度切换/条形/去重用户数 +
  分页列表表)。
- 路由 /_authenticated/telemetry(ROLE.ADMIN 守门),系统设置工作区侧栏加「Client Telemetry」入口。
- routeTree.gen.ts 由 router-generator 重新生成(仅 +22 行 telemetry 路由,无重排)。

测试:model/telemetry_query_test.go 覆盖列表过滤/分页/最新在前、聚合计数 + 去重用户、
维度白名单拒绝(注入防线)。go build ./... + model/controller/router 全绿;前端 tsc 仅
resources-page 一处既有报错(与本改动无关)。

影响面:Manager ✅(新增 admin 只读端点 + 前端页面)/ 计费 ❌(遥测隔离)/ 密钥 ❌ /
审计 ❌ / Client ❌ / agent_swarm ❌ / 发布链路 ❌。隐私:沿用 #24 口径,载荷无用户内容,
device_id 账号可关联故管理员才可读。

Refs #24。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:13:38 +08:00
chenchenandClaude Opus 4.8 2c60af0216 feat(swarm): #46/#28 暴露 input/result 代理端点(I/O 闭环)
agent_swarm PR#41 已合并 main(契约冻结),HM 补齐客户端+swarm 双方点名需要的
两条透传端点(均设备签名,挂 /api/heicode/swarms/:id 下):

- POST /swarms/:id/input  → 代理 Swarm POST …/{deployment_id}/input(追加用户输入,
  注入 source=user_append;终态 run 自动 reopen,stopped 拒绝)。写路,需
  SWARM_RUNTIME_ENABLED+base+token,缺一即拒不伪造受理。**指令文本仅转发,绝不落
  HM 日志/审计/事件**(#40 原文不进事件流 + #46 勿落日志);审计只记发生过一次 append。
- GET  /swarms/:id/result → 代理 Swarm GET …/{deployment_id}/result,返回
  {summary, deliverable, artifacts[], termination_reason, status};产物按 uri 取非内联。
  读路,需 base+token(与派发开关解耦);透出前 stripSensitiveKeys 递归剔除 secret_ref 等兜底。

新增:callSwarmRuntimeJSON(通用 JSON 调用 + envelope 归一,错误信息不含请求体)、
agentRuntimeSwarm{Input,Result}Path(默认 create-path 基路径,SWARM_RUNTIME_{INPUT,RESULT}_PATH 可覆盖)。

测试:swarm_io_proxy_test.go 覆盖路径构造(默认/空回退/env 覆盖)、input 转发(方法/路径/鉴权/body)、
result 解析 + deliverable/artifacts 脱敏、运行时非 2xx 报错。go build ./... + controller/router 全绿。

影响面:Manager ✅(透传端点)/ agent_swarm ✅(消费 PR#41 契约)/ Client ✅(驾驶舱 I/O 闭环)/
Agent ❌ / CodeGW ❌ / 计费 ❌ / 密钥 ❌(不经手 secret)/ 审计 ✅(input_appended,不含原文)/ 发布链路 ❌

Refs #46 #28。Depends-on-contract: agent_swarm PR#41 (已合并 main)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:41:39 +08:00
chenchenandClaude Opus 4.8 c494e9576e feat(swarm): #60 模型 key 注入(方案A)+ pool_terminated 吊销握手
对接 agent_swarm PR#43 定死的参数,实现 HM 侧的 per-user 模型 key 注入:

- A.1 粒度/命名:为认证用户 mint 一把 per-user sk-(getOrMintSwarmModelToken),
  跨该用户所有 swarm run 复用;KV 密文名 swarm-model-key-<user_id>。
- A.2 KV value:JSON {"openai_api_key":"sk-..."},对齐 Swarm
  orchestrator/agent_launcher._extract_model_key 解析字段。
- A.4 OPENAI_API_BASE 为 Swarm 部署常量,HM 不经 billing_context 下发。
- A.5 吊销:事件驱动。注册 swarm.pool_terminated(swarm_lifecycle 类),
  回调 handleSwarmPoolTerminated 删 KV 密文 + 软删 token;user_id 优先取
  payload,缺失回退 deployment 上下文。

createAgentDeploymentFromPlan 在 swarm 模式且 billing_context 未自带 secret_ref
时调 provisionSwarmModelKey,把 azkv:// 引用写进 billing_context.secret_ref 下发。
Key Vault 未配置/不可达时降级:记日志、secret_ref 留空,不阻断 create(联调前可用)。
明文 sk- 仅经 secret_ref 服务端解析,绝不入代码/日志/事件/argv。

测试:swarm_model_key_test.go 覆盖 per-user 复用、吊销重 mint、pool_terminated
回调(payload/上下文两路 user_id)、非目标事件不误吊销、事件已注册。

影响面:agent_swarm(契约消费侧)、密钥(per-user sk- mint+吊销)、计费
(走 User.Quota)、审计(回调入库+审计事件)。不影响 Client/CodeGW/发布链路。
未决依赖:A.3(Swarm 读 heicode-kv 的 RBAC,运维授权 pending)是端到端联调前置,
不阻塞本 PR。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:30:32 +08:00
chenchenandClaude Opus 4.8 c55e60b3b3 test(swarm): adapter 用例走 admin 旁路通过蜂群订阅 gate
PR#70 给 AgentCreateUserSwarm 加了订阅 gate 后,TestAgentUserSwarmsAdapter
CreatesScopedDeployment(普通用户、无订阅)被 POLICY_REJECTED。该用例聚焦
adapter 的用户作用域而非 gate 本身(gate 由 TestGetUserSwarmEnabled 覆盖),
故走 admin 旁路。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:28:37 +08:00
chenchenandClaude Opus 4.8 a0616eaebf feat(swarm): 按订阅套餐控制蜂群使用权(管理员可配)
需求:管理员在 HM 控制台按订阅套餐控制谁能用蜂群。照搬现有 MaxAgents(#8)模式
(per-plan、管理员设、不硬编码 tier)。

后端:
- model SubscriptionPlan 加 SwarmEnabled bool(default false;AutoMigrate 加列,跨三库)。
- model GetUserSwarmEnabled(userId):用户任一活跃套餐 SwarmEnabled=true 即放行(镜像
  GetUserMaxAgents);无套餐/DB 不可用 => false(opt-in)。
- controller AgentCreateUserSwarm gate:普通用户须有开通套餐,管理员(role>=admin)绕过(测试/运维)。
- controller 套餐 admin upsert 字段映射加 swarm_enabled(create 整结构已自动带)。

前端(web/default 套餐编辑):
- plan-form schema/defaults/映射 + types 加 swarm_enabled。
- 套餐编辑抽屉加「蜂群使用权」Switch(镜像 enabled)。
- zh.json 加 "Swarm Access" → 蜂群使用权(en 用 key 兜底)。

默认关闭(opt-in)、管理员逐套餐勾选;gate 在 swarm 创建入口,与 swarm 运行时是否启用无关。
测试 TestGetUserSwarmEnabled(无套餐/开通/未开通/过期/多套餐/非法 user)。build+vet+model 测试全绿。

影响面:HM 订阅模型(加字段)+ swarm 创建鉴权 + admin UI;不涉计费扣费/密钥/swarm 契约。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:55:55 +08:00
chenchenandClaude Opus 4.8 a5419f9a15 feat(swarm): GET /swarms/:id 详情补 resource_grants/budget/audit_logs (#66 里程碑07/08)
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) <noreply@anthropic.com>
2026-06-11 17:24:09 +08:00
FastheiandGitHub 219bbf1836 Merge pull request #64 from xmindlab-heicode/fix/reland-swarm-stop-sse
fix: re-land swarm stop + events SSE into main (#61 误合到错误 base)
2026-06-10 21:55:15 +08:00
chenchenandClaude Opus 4.8 c3f51fe479 feat: wire swarm stop to runtime + add events SSE stream (#45/#46)
契约已冻结(agent_swarm#14 runtime-contract v1 / #15 event-schema v1),HM 侧据此
落地两项原本 deferred 的能力,叠在读侧适配(PR #59)之上:

- stop(#45):HeicodeStopSwarm 真实调用运行时冻结路径
  POST /api/agent/swarm/deployments/{deployment_id}/stop,复用 agent_runtime_client
  的配置/URL/信封解析;Bearer SWARM_RUNTIME_SERVICE_TOKEN + X-Idempotency-Key 幂等。
  仅 SWARM_RUNTIME_ENABLED=true 且配齐 base_url+token 时发起,否则 POLICY_REJECTED,
  绝不伪造 accepted;终态不抢写,由 swarm.stopped 回调写回。
- events SSE(#46 读侧):GET /api/heicode/swarms/:id/events/stream?after=,与
  events?after 同源(HM 持久化回调事件,按 user_id 收口 + payload 脱敏),命中终态
  (swarm.completed/failed/stopped)/客户端断开/超时(30min)收流。不调运行时。
- docs:刷新 heicode-swarm-deferred.md(登记冻结契约、读侧/stop/SSE 已落地、唯一剩余
  计费缺口 #60)与 heicode-desktop-client-api.md §5.2(SSE + stop 端点)。

影响面:Client(新增 SSE 端点 + stop 行为变化)、agent_swarm(按冻结契约调用其 stop)。
不涉及 Manager↔AM、密钥、计费扣费逻辑(计费缺口 #60 仍阻塞于 agent_swarm#16)。
测试:callSwarmRuntimeStop(httptest 校验路径/鉴权/幂等头/信封解析/错误路径/runtime
id 回退)+ swarmEventIsTerminal;go build ./... 与 controller 测试全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:43:00 +08:00
FastheiandGitHub 5470ba2c13 Merge pull request #63 from xmindlab-heicode/feat/aws-full-resource-discovery
feat(discovery): AWS 全量资源发现 — EC2/RDS/S3/Lambda List/Describe (#62)
2026-06-10 21:35:03 +08:00
FastheiandGitHub bb29399782 Merge pull request #59 from xmindlab-heicode/feat/swarm-contract-adoption-v1
feat(swarm): adopt frozen agent_swarm contract v1 — events/sequence/artifact/status (#45/#46)
2026-06-10 21:29:49 +08:00
chenchenandClaude Opus 4.8 292504735e feat(swarm): add goal_summary to swarm status view (#45/#28 consumer ask)
@Mem0ried 客户端 consumer 验收(#59)指出 GET /swarms/:id 缺 goal_summary —— Run 列表
只能显示 deployment_id/status,体验差。补:swarmDeploymentView 增加 goal_summary,从持久化
plan_json 顶层 objective 提取,折叠空白为单行 + 截断(200 rune)+ RedactText 兜底;取不到
(无 plan / 无 objective / 坏 JSON)返回空串,不臆造。list 与 detail 同走 swarmDeploymentView,
两处都带上。

文档 docs/integration/heicode-desktop-client-api.md §5.2 状态样例补 goal_summary 字段说明。
测试 TestSwarmGoalSummary 覆盖:空/坏 JSON/无 objective→空;多行多空格折叠;误入 sk- 被脱敏;
超长截断带省略号。go build ./... 与 controller 测试全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:17:32 +08:00
chenchenandClaude Opus 4.8 8529340a40 feat(discovery): AWS full-account resource discovery — EC2/RDS/S3/Lambda List/Describe (#62)
#5 的 AWS 适配器只走 Resource Groups Tagging API GetResources,仅覆盖「已打标签」资源,
不含未打标签的 EC2/RDS/S3 等(代码已据实声明,#5 全量诉求未据此关闭)。本次补齐:

- 新增服务级发现(aws_resource_discovery_services.go):EC2 DescribeInstances、
  RDS DescribeDBInstances、S3 ListBuckets、Lambda ListFunctions,均复用已通过 AWS
  get-vanilla 向量验证的 SigV4 签名(awsSigV4Authorization),无 AWS SDK 依赖。
- discoverAWS 改为编排:tagged + 各服务级结果按 ARN 合并去重(tagged 优先保留,标签信息更全);
  原 tagged 逻辑保留为 discoverAWSTagged。
- 失败降级:单服务调用失败(如缺该服务读权限)只记日志并跳过,不让整次发现失败;仅当
  tagged 报错且无任何结果时才抛原始错误,保证凭据/区域问题可见。
- 区域:EC2/RDS/Lambda 用账号配置 region;S3 ListBuckets 全局(us-east-1 签名)。跨 region
  扫描不在本次范围(单 region),后续可在 #62 跟踪。

安全:沿用 applyDiscoveredResourceFields 既有约束——发现出的资源 SecretRef 恒空,绝不继承
账号 secret_ref;凭据仍走 cloud_account secret 链路,不落代码/日志。

影响面:仅 HM 内部云资源发现;不涉及 Client、AM、agent_swarm、计费、审计字段、密钥写入。
测试:parseEC2Instances/parseRDSInstances/parseS3Buckets/parseLambdaFunctions(真实响应
样本 XML/JSON)+ mergeDedupeByExternalID(去重/优先/空 id);SigV4 向量与既有发现测试不变;
go build ./... 与 controller 测试全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:38:17 +08:00
chenchenandClaude Opus 4.8 56a9b02a25 feat(swarm): adopt frozen agent_swarm contract v1 (read-side) (#45/#46)
按 agent_swarm#14/#15 冻结契约做 HM 读侧适配(客户端 #28 消费):
- 注册 6 类新事件(event-schema v1):swarm.completed/failed/stopped、approval.approved/rejected、
  handoff.created(categories + requiredFields 两表;必填先最小集,待 agent_swarm PR #28 §4 精校)。
- AgentCallbackEvent 增 Sequence(per-swarm 严格递增序号),回调入库透传 envelope.sequence;
  事件视图暴露 sequence 供客户端去重/排序。游标仍用稳定 id(next_after)避免 sequence 未全量
  上线时回归。
- 脱敏键补 credential_ref/signing_secret_ref(envelope 按设计透传 azkv:// 引用,客户端视图剔除)。
- artifact.created → 扁平视图 {uri,checksum,task_id,size_bytes?,created_at}(无 secret_ref;
  size 未知省略不伪造)。
- 状态展示映射 §4.1:display_status(blocked→degraded;不臆造 preparing/verifying)。

测试:状态映射、artifact 视图(脱敏 + size 省略)、6 类事件注册;controller+model 全回归通过,
go build/vet 干净。文档 §5.2 更新。

未含(下一 PR):stop 真实运行时接入(写路径,复用 agentRuntimeClientConfigForMode("swarm"))。

Affects: Manager only(只读查询契约适配)。AgentCallbackEvent 加列(AutoMigrate);无计费改动。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:01:32 +08:00
FastheiandGitHub 3af911257f Merge pull request #56 from xmindlab-heicode/feat/agent-endpoint-security-scheme
feat(agent): return A2A endpoint security scheme/profile (#55)
2026-06-10 16:27:34 +08:00
chenchenandClaude Opus 4.8 2500f17189 feat(agent): return A2A endpoint security scheme/profile (#55)
客户端 #25/#55:A2A 直连方需 Manager 在 agent endpoint 元数据给出安全通道信息,才能在
生产强制 HTTPS。agent 对象(列表/详情/部署/状态返回)新增 security:
- scheme: http | https(从 subdomain 推断)
- security_profile: none | tls(显式 https→tls;http 或裸主机→none)
- secure: profile != none

保守口径:无法确证 TLS 即 none,宁可客户端在生产拒绝也不回传"看似安全"的明文端点。
客户端置 HEICODE_AGENT_REQUIRE_SECURE=1 即据此强制(secure=false→拒绝)。AM 上 HTTPS/mTLS
listener(azgy)后 profile 自动变 tls,客户端无需改包。元数据不含任何 secret_ref。

纯函数 agentEndpointSecurity + 单测;文档 §5/§6 更新。Manager only,无计费/审计改动。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:37:28 +08:00
FastheiandGitHub 3823ffb81d Merge pull request #51 from xmindlab-heicode/feat/preflight-confirm-audit-version
feat(preflight): confirm + audit + tamper-proof version check (#41)
2026-06-10 15:36:42 +08:00
FastheiandGitHub 33f60922cb Merge branch 'main' into feat/preflight-confirm-audit-version 2026-06-10 15:36:14 +08:00
FastheiandGitHub 88793b1066 Merge pull request #52 from xmindlab-heicode/feat/cloud-discovery-aws-gcp
feat(discovery): provider-agnostic cloud discovery — AWS + GCP adapters (#5)
2026-06-10 15:33:58 +08:00
FastheiandGitHub 2332749013 Merge pull request #53 from xmindlab-heicode/feat/hm-swarm-query-phase1
feat(swarm): HM-side Swarm Run read-only query — Phase1 (#45)
2026-06-10 15:33:40 +08:00
chenchenandClaude Opus 4.8 19640b44f5 fix(preflight): address #41 review — persisted confirmation, deploy ready re-check, template-aware version
回应 Fasthei 复审(PR #51 CHANGES_REQUESTED):
1. 持久化确认记录(强一致):新增 model.PreflightConfirmation 表 + InsertPreflightConfirmation +
   PreflightConfirmationExists。confirm 时落库(默认 TTL=HEICODE_PREFLIGHT_CONFIRMATION_TTL_SECONDS
   =3600s,可设 0 不过期),写失败直接报错(非 best-effort)。部署侧要求该版本存在未过期确认记录
   → 杜绝直接拿 GET version 绕过 confirm/审计。
2. 部署重新校验 Ready:verifyDeployPreflight 增加 summary.Ready 检查 —— 预算/agent_slot 等
   易变项不进版本哈希,故部署时重查,防 confirm 后余额耗尽/槽位占满仍启动。
3. 版本哈希纳入模板安全面:computePreflightVersion 加 tplDigest(definition+model+name 摘要),
   管理员改同一 template_key 的 definition/model 后旧确认失效。补 TestComputePreflightVersion_ChangesOnTemplateEdit。
4. 审计降为附加流:强一致确认记录作为部署门禁;审计 preflight.confirmed 互补。

测试:PreflightConfirmationExists(命中/版本不符/跨用户/过期/不过期/空参)、PreflightBindingKey、
模板变更翻转版本。TestMain + 生产迁移注册 PreflightConfirmation。controller+model 全回归通过。
文档 §4.1.1 更新。

Affects: Manager only(新增 preflight_confirmations 表 + 部署门禁强化)。无计费改动。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:28:07 +08:00
chenchen 64ba58357f Merge remote-tracking branch 'origin/main' into feat/preflight-confirm-audit-version 2026-06-10 15:21:37 +08:00
chenchenandClaude Opus 4.8 df8fa80eba fix(discovery): address #5 review — no secret inheritance, GCP URL, tagged-only scope
回应 Fasthei 复审(PR #52 CHANGES_REQUESTED):
1. 发现出的子资源不再继承账号 secret_ref:抽出 DB 无关的 applyDiscoveredResourceFields,
   恒置 SecretRef=""(避免账号级凭据引用经 resourceToResponse / grant manifest 扩散到每个
   VM/S3/DB)。加 TestApplyDiscoveredResourceFields_NoSecretInheritance。
3. GCP searchAllResources URL 修正:gcpSearchAllResourcesURL 不再 PathEscape 整个 scope
   (slash 是路径模板一部分),生成 /v1/projects/<id>:searchAllResources。加 TestGCPSearchAllResourcesURL。
2. AWS 覆盖范围据实声明:Resource Groups Tagging GetResources 仅覆盖 tagged/曾 tagged 资源,
   不覆盖未打标签 EC2/RDS/S3;注释明确「tagged resources only」,#5 全量发现诉求不据此关闭。

controller 全套测试通过,go build/vet 干净。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:29:57 +08:00
chenchen 1e28ab1100 Merge remote-tracking branch 'origin/main' into feat/cloud-discovery-aws-gcp 2026-06-10 14:25:00 +08:00
chenchenandClaude Opus 4.8 b9d9eddf7b fix(swarm): address #45 review — payload redaction, user-scoped events, no fake stop
回应 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>
2026-06-10 14:23:58 +08:00
FastheiandGitHub 87c95e526b Merge pull request #54 from xmindlab-heicode/docs/telemetry-prod-enablement-checklist
docs(telemetry): production enablement checklist 定稿 (#44)
2026-06-10 13:54:05 +08:00
chenchenandClaude Opus 4.8 06f0971465 docs(telemetry): reframe enablement gate as privacy-disclosure-published, not legal signoff (#44)
去掉「法务/产品签字」「需真人」表述:开启遥测的前置是「隐私披露文档已发布且经 PR 评审通过」
这一文档/评审条件,不是人工法务签字。符合团队留言只写技术结论/条件的约定。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:42:53 +08:00
chenchenandClaude Opus 4.8 612cccecb7 docs(telemetry): production enablement checklist 定稿 (#44)
#44 的「生产开启 telemetry 前置 checklist 定稿」工程交付:
docs/integration/telemetry-production-enablement-checklist.md。

涵盖:① 代码侧控制核验(默认 off/410、白名单 #42、8KiB 上限、保留期 #43、服务端脱敏、
不计费)② 生产配置确认(HEICODE_TELEMETRY_ENABLED/RETENTION_DAYS 等)③ 隐私/法务
签字硬前置(设备 ID 可关联账号披露 + 法务签字,owner=文档/合规,口径 @Fasthei;跟踪 #34)
④ 上线/回滚验证(410 基线→开启→抽查脱敏→回滚演练)⑤ 结论门。docs README 已索引。

完成 #44 的 checklist 定稿 DoD;隐私披露(#34)与法务签字仍是开启的人工前置。Docs only。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:38:07 +08:00
chenchenandClaude Opus 4.8 59b13ba824 feat(swarm): HM-side Swarm Run read-only query — Phase1 (#45)
#45 Phase1 的只读查询(list/status/events?after/artifacts),全部基于 HM 已持久化的
运行时回调数据(Swarm → HM 带签名回调,见 agent_callback.go),**无需实时调 Swarm**,
因此不被 agent_swarm#2 契约冻结阻塞、返工风险低:

- GET /api/heicode/swarms — 列出当前用户的 swarm 运行(AgentDeployment, sub_mode=swarm 或有 runtime_swarm_id)
- GET /api/heicode/swarms/:id — 状态(:id = deployment_id/swarm_id/correlation_id 任一)
- GET /api/heicode/swarms/:id/events?after=&limit= — 事件增量拉取(id 游标 next_after,oldest-first)
  新增 model.ListSwarmCallbackEventsAfter(按 deployment_id/swarm_id + id>after)
- GET /api/heicode/swarms/:id/artifacts — 从已存事件(event_type 含 artifact)派生
- POST /api/heicode/swarms/:id/stop — 唯一写操作;在 agent_swarm#2 冻结 + SWARM_RUNTIME_ENABLED=true
  前默认关闭并明确提示(不臆造未冻结写接口)

字段口径对齐 agent_swarm/docs/integration/runtime-contract.md(deployment_id↔swarm_id↔
manager_deployment_id;状态机 waiting_approval→running→…)。所有查询按 user 作用域,视图脱敏
(不含 plan/payload 大字段与凭据)。

测试:TestListSwarmCallbackEventsAfter(游标/过滤/空标识);TestMain 迁移 AgentDeployment +
AgentCallbackEvent。go build/vet 干净,controller+model 全套回归通过。文档 §5.2。

Affects: Manager only(新增只读查询端点 + 一个 gated 写端点)。无计费/审计 schema 改动;
不依赖未冻结契约。stop 真实接入随 agent_swarm#2 冻结落地。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:24:34 +08:00
zsbgnw12andGitHub da55285414 Merge pull request #50 from xmindlab-heicode/docs/hm-swarm-refresh-agent-swarm
docs(swarm): refresh HM-side swarm docs to current agent_swarm (repo name + model + contract)
2026-06-10 13:11:23 +08:00
zsbgnw12andGitHub 9ff0a7f9c5 Merge pull request #38 from xmindlab-heicode/docs/client-api-available-models-telemetry-usage
docs(client-api): available-models + config + telemetry + agent usage + #30 billing semantics (#35)
2026-06-10 13:11:19 +08:00
chenchenandClaude Opus 4.8 3fb07ad5fb feat(discovery): provider-agnostic cloud discovery — AWS + GCP adapters (#5)
#5 三项 DoD 全部落地(纯 Go,无云 SDK,REST + std crypto,非 mock):

- 抽象 provider 接口(DoD #3):cloudDiscoveryProvider{ name, discover } + 统一
  upsertDiscoveredCloudResources + 跨云一致分类 classifyCloudResourceType(vm/
  database/blob/cloud_resource)。新增 dispatcher DiscoverCloudResources 按
  account.Provider 路由 azure/aws/gcp。Azure 适配器复用既有 ARM 客户端,三家同抽象。
- AWS adapter(DoD #1):纯 Go AWS Signature V4(crypto/hmac+sha256)+ Resource
  Groups Tagging API GetResources(分页)→ ARN 归一化。SigV4 实现以 AWS 官方
  "get-vanilla" 测试向量验证签名正确(TestAWSSigV4_VanillaVector)。
- GCP adapter(DoD #2):服务账号 RS256 JWT 断言 → OAuth2 token → Cloud Asset
  Inventory searchAllResources(分页)→ asset 归一化。JWT 以生成密钥 RS256 验签
  roundtrip 验证(TestBuildGCPAssertion_RS256Roundtrip)。

路由:新增 POST /api/resources/:id/discover-cloud(按 provider 路由);保留既有
/:id/azure/discover 向后兼容。落库与 Azure 一致(resource_type=cloud_resource,
跨云类别入 metadata.classified_type),幂等 upsert(user+type+provider+external_id)。

测试:分类 / ARN 解析 / SigV4 官方向量 / GCP RS256 roundtrip;controller 全套通过,
go build/vet 干净。

Affects: Manager only(资源发现,新增只读发现端点 + 抽象层)。无计费/审计/部署改动;
凭据经既有 secret store 读取,不落明文。

Closes #5

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:07:48 +08:00
chenchenandClaude Opus 4.8 9a073e5b41 feat(preflight): confirm + audit + tamper-proof version check (#41)
完成 preflight EPIC(#29)的最后一子项 #41:

- POST /api/heicode/preflight/confirm:重算摘要 → 仅 ready 时可确认 → 派生防篡改
  版本哈希 → 写审计事件 preflight.confirmed(谁/何时/哪个 version)→ 返回 version。
- 防篡改版本 computePreflightVersion:在**稳定安全面**(template + 资源
  binding_id/type/provider/name/status/has_secret + 高危 enum + 必需缺失项)上做
  sha256,刻意排除易变预算数字/agent_slot,避免版本无意义抖动。
- 部署校验:POST /api/heicode/agents 新增可选 preflight_version。默认仅在带了它时
  校验(向后兼容);HEICODE_PREFLIGHT_REQUIRED=true 时强制。确认后资源/模板/高危面
  漂移或被篡改 → 版本不匹配 → 部署拒绝。
- 重构:抽出 buildPreflightSummary(GET/confirm/deploy 共用);GET 现也回 version。

测试:版本确定性/稳定性(不随预算变)、资源篡改翻转版本、normalizeBindingIDs。
go build/vet 干净,controller preflight 测试全过。文档 §4.1/§4.1.1 更新。

Affects: Manager only(新增 confirm 端点 + 部署可选校验,默认向后兼容)。
无计费/审计 schema 改动(复用既有 agent_audit_events)。

Closes #41

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:55:40 +08:00
chenchenandClaude Opus 4.8 699fa5ef44 docs(swarm): refresh HM-side swarm docs to current agent_swarm state
HM 端文档里的蜂群描述还是旧的(错仓名 + 旧模型),据实更新为当前 agent_swarm:

- 仓名:`HeiCode-Swarm` → `agent_swarm`(GitHub xmindlab-heicode/agent_swarm;产品名 HeiCode Swarm)。
- 模型:旧「HM 主导编排 / 仅 /tasks」→ 当前 Master-Agent 编排(分解→派发专家并行→协作/移交→评审/重做→汇总;Orchestrator FastAPI + Redis + WebSocket + Prometheus)。
- 契约:agent_swarm 已起草正式契约 docs/integration/runtime-contract.md(对齐 heicode-am-contract),待 Manager Runtime Team 评审冻结;已实现 create/status/tasks/logs/events/metrics/workflow/diagnostics/stop/approvals(带 deployment_id)。
- issue 引用:`agent_swarm#1`(执行面缺口,已关闭)→ 活动 EPIC `agent_swarm#2`(主链路接入契约冻结);HM 侧前置 #45/#46。

文件:
- docs/integration/heicode-swarm-deferred.md:勘误抬头 + §1 模型/接入状态 + §2 归属表(仓名/契约/接口) + §4 收尾据实更新。
- heicode/docker-compose.azure-vm.yml:SWARM_RUNTIME_* 注释更正(agent_swarm,deferred,当前关闭)。
- Heicode-Manager-生产配置与账号交接清单.md §7 + Orchestrator 地址标签:加勘误 banner,保留 env 历史记录但标注为旧模型/当前关闭。
- docs/deployment/Heicode-Manager-更换部署服务配置清单.md §2.2:加勘误 banner。

HM 不实现 swarm runtime 的裁定不变;仅把"蜂群现状"事实对齐。未改 env 变量名/行为(SWARM_RUNTIME_* 仍 disabled),未动 legacy-teardown 历史记录。Docs/注释 only。

Affects: 文档/注释 only。无代码、无计费/审计/部署行为改动。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:15:57 +08:00
FastheiandGitHub c39ed6a070 Merge pull request #48 from xmindlab-heicode/feat/agent-preflight-checklist
feat(preflight): read-only preflight checklist + execution summary (#39, #40)
2026-06-10 12:08:51 +08:00
FastheiandGitHub a31745316b Merge pull request #49 from xmindlab-heicode/feat/agent-usage-budget-remaining
feat(usage): expose budget_remaining on agent usage endpoint (#9)
2026-06-10 12:08:23 +08:00
FastheiandGitHub b2f5f3c5ea Merge pull request #47 from xmindlab-heicode/test/telemetry-retention-purge-coverage
test(telemetry): cover retention purge deletion (#43)
2026-06-10 12:06:13 +08:00
chenchenandClaude Opus 4.8 df83588720 docs(client-api): add budget_remaining to agent usage §5.1 (#9)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:36:14 +08:00
chenchenandClaude Opus 4.8 46cc7293e2 feat(usage): expose budget_remaining on agent usage endpoint (#9)
#9 的最后一项 DoD「预算剩余」:GET /api/heicode/agents/:id/usage 新增
budget_remaining = 用户钱包剩余额度(int64;-1 表示读取失败,不阻断用量展示)。
Agent 模型调用经隐藏 token 计费到 user.Quota(#30),故"本任务预算剩余"= 用户剩余额度。
与既有 quota/quota_per_unit 同口径。

#9 的①审计脱敏(PR #11)+②deployment 维度用量(SumAgentUsage/usage 端点)此前已闭环,
本 PR 补齐 budget_remaining。文档 §5.1 随 PR #38 更新(usage 章节在该 PR)。

Affects: Manager only(usage 响应新增只读字段)。无计费/审计 schema 改动。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:34:24 +08:00
chenchenandClaude Opus 4.8 4502d6b5bc feat(preflight): read-only preflight checklist + execution summary (#39, #40)
新增 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 <noreply@anthropic.com>
2026-06-10 11:29:34 +08:00
chenchenandClaude Opus 4.8 31bf13fc36 test(telemetry): cover retention purge deletion (#43)
#37 shipped the telemetry retention task + model.DeleteTelemetryEventsBefore but
lacked a test for the deletion itself. Add TestDeleteTelemetryEventsBefore
(strict-< cutoff, retains at/after, idempotent) and migrate TelemetryEvent in the
model TestMain so it has a table.

Closes the "测试覆盖过期清理" DoD item of #43. Test-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:37:44 +08:00
chenchen f1e0973103 Merge remote-tracking branch 'origin/main' into docs/client-api-available-models-telemetry-usage 2026-06-10 10:32:41 +08:00
FastheiandGitHub dc75485be8 Merge pull request #37 from xmindlab-heicode/feat/telemetry-retention-and-context-whitelist
feat(telemetry): retention purge + context field whitelist + size caps (#32)
2026-06-10 01:10:24 +08:00
FastheiandGitHub aea6b923d1 Merge pull request #36 from xmindlab-heicode/fix/agent-token-cryptosecret-and-purge-guard
fix(agent,secret): enforce CRYPTO_SECRET for agent deploy (#31) + guard unscoped vault purge (#33)
2026-06-10 01:08:45 +08:00
chenchenandClaude Opus 4.8 d2d9a22ae4 docs(client-api): add available-models, config, telemetry, agent usage + hidden-token billing semantics (#35, #30)
Bring docs/integration/heicode-desktop-client-api.md in lock-step with merged
endpoints:

- §3.1 /api/heicode/available-models: logged-in user model list is the single
  source of truth (no local presets / no CodeGW channel backend); exposes only
  model_id/display_name/default; forbidden fields enumerated.
- §5.1 /api/heicode/agents/{id}/usage: token-name aggregation, time window,
  empty-data semantics, relation to billing logs. Includes the #30 hidden agent
  model token billing-collection semantics: UnlimitedQuota only waives the
  token's own remaining-quota cap, it does NOT bypass user.Quota — HM still
  checks user quota and settles to wallet/subscription via the billing
  expression; token revoked on stop/delete; hidden from user UI but auditable.
- §7.1 /api/heicode/config: telemetry enabled/endpoint/max_batch/flush/retention_days.
- §7.2 /api/heicode/telemetry/events: default-off 410 kill switch, V2 device
  signature, top-level array 1-20 / <=256KB, server redaction, context field
  whitelist (#32), 4xx-drop/5xx-retry, retention; production enable-gate note.
- Updated §10 endpoint inventory.

Docs-only; no code change. heicodeDocs org-level sync tracked in #34.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:45:29 +08:00
chenchenandClaude Opus 4.8 2e37495133 feat(telemetry): retention purge + context field whitelist + size caps (#32)
Telemetry up-gating hardening (code portion of #32):

- Context field whitelist: telemetry `context` is filtered to a small set of
  non-content diagnostic keys (route/retryable/phase/exit_code/duration_ms/
  attempt) before persistence. Unknown keys — including potentially identifying
  ones (email, full file path, prompt, raw IP) — are dropped, so a client
  regression cannot land arbitrary JSON in the store. Empty/unparseable/no-allowed-key
  context is dropped to "".
- Per-field size cap: stack_top and context are truncated to 8KiB after
  redaction (backstop against unbounded blobs within batch limits).
- Retention: daily master-only task deletes telemetry rows older than
  HEICODE_TELEMETRY_RETENTION_DAYS (default 30; <=0 disables).
  HEICODE_TELEMETRY_RETENTION_INTERVAL_HOURS (default 24) sets cadence.
  model.DeleteTelemetryEventsBefore(cutoff) + controller.StartTelemetryRetentionTask()
  wired into main.go under IsMasterNode.
- GET /api/heicode/config telemetry block now surfaces retention_days for
  client/admin transparency.

Tests: whitelist drop/keep, size cap, redaction-within-allowed-key. go build/vet
clean; controller telemetry tests pass.

Affects: Manager only (telemetry ingest + retention). No billing/consume-log
change (telemetry still never bills). Privacy-doc disclosure + production
enable-checklist portions of #32 tracked in heicodeDocs sync (#34) / desktop
client API docs (#35).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:40:23 +08:00
chenchenandClaude Opus 4.8 8fe1f5e131 fix(agent,secret): enforce CRYPTO_SECRET for agent deploy (#31) and guard unscoped vault purge (#33)
#31: HeicodeDeployAgent now refuses to deploy unless CRYPTO_SECRET is explicitly
configured, so the per-agent access_token is sealed with a key that survives a
container restart. common.CryptoSecret is never literally "" (defaults to
uuid/SessionSecret), so the sealAgentToken plaintext fallback was effectively
unreachable; the real hazard is an ephemeral random seal key making tokens
undecryptable after restart. Dev-only override: HEICODE_ALLOW_PLAINTEXT_AGENT_TOKEN_IN_DEV=true.
Verified prod container has CRYPTO_SECRET set (64 chars) -> deploy stays allowed.

#33: StartSecretPurgeTask refuses to start a whole-vault purge when
HEICODE_SECRET_PURGE_NAME_PREFIX is empty unless HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true,
so HM never permanently purges another tenant's soft-deleted secrets in a shared
vault. Logs the resolved purge scope at startup.

Both gates extracted into pure, unit-tested helpers (agentTokenSealKeyConfigured,
secretPurgeScopeAllowed). Affects: Manager only (Agent deploy + Secret lifecycle).
No Client/Swarm/billing/audit schema change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:33:47 +08:00
818f1412d5 feat(heicode): 客户端错误遥测上报端点(默认关闭)(#24) (#27)
* feat(heicode): 客户端错误遥测上报端点(默认关闭)(#24)

按客户端 winos#23 契约 + 权威 schema 实现,结合 HM 入库逻辑:

- POST /api/heicode/telemetry/events,挂 UserOrV2DeviceAuth(设备配对鉴权)。
- 接收顶层 JSON 数组(非包裹),批量 1-20、<=256KB;校验 body client_id 等于已验签
  设备(X-Heicode-Device-Id),不一致 403;无设备身份拒绝。
- 真实 4xx/5xx 码(400 非数组、413 超限、403 设备、410 关闭),让客户端"4xx 丢弃"
  语义生效;2xx 返回 {accepted:n}。
- 独立表 telemetry_events,与计费完全隔离:不写 consume log、不碰 quota。
- 宽松入库(最大化采集):未知枚举 / 哨兵 app_version(0.0.0-heicode-local)/ 缺字段
  原样入库;schema_version 缺省 1;stack_top/context 存 TEXT(JSON);记 user_id 作
  device 到 account 关联 + 服务端 received_at。
- 默认 HEICODE_TELEMETRY_ENABLED=false 时返回 410(kill switch);隐私政策更新 +
  端点下发形态确认前不开启外发。

测试用客户端仿真夹具:parseTelemetryBatch / toModel 映射与默认 / 拒绝非数组,全过。

Refs #24(上线门槛:隐私政策 §2 如实披露 + 下发形态 + 去重;见工单评论)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(heicode): GET /api/heicode/config 下发 telemetry 配置/kill-switch (#24)

按客户端 #24 拍板:选独立 config 端点(而非塞登录响应),便于 kill switch 在
会话内传导、不依赖重登录。返回 telemetry 块 {enabled, endpoint, max_batch,
flush_interval_sec};enabled 取 HEICODE_TELEMETRY_ENABLED(默认 false)。
未鉴权全局只读(同 capabilities 姿态)。

测试 heicode_config_test.go:enabled 反映 env、endpoint 与摄入路由一致、缺省 false。

Refs #24

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(telemetry): 服务端二次脱敏 stack_top/context + 明确生产门槛 (#24 review)

按 Fasthei 复审意见补隐私门槛:

- 服务端纵深防御脱敏:新增导出 model.RedactText(包装已上线的 redactAuditSecrets,
  #11),在 telemetry 入库前对 stack_top / context 再脱敏一遍(sk-/Bearer/URL
  token/JSON 密钥字段),即便客户端漏脱敏也不会把明文密钥落库。
- 测试 TestTelemetryToModel_RedactsSecrets:stack_top 里的 sk-、context 里的
  Bearer token 被打码,非密钥内容保留。
- 端点默认 HEICODE_TELEMETRY_ENABLED=false,关时 410;隐私政策披露完成前生产
  不得开启外发(见 #24 评论记录产品/法务状态)。

go build / vet 干净;controller 测试通过。

Refs #24

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: chenchen <chenchen@xinghanlab.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:16:29 +08:00
FastheiandGitHub 758e2d0342 Merge pull request #26 from xmindlab-heicode/feat/heicode-available-models
feat(heicode): /api/heicode/available-models 客户端模型列表收口 (#25)
2026-06-09 16:29:35 +08:00
FastheiandGitHub 3e0c935ad0 Merge pull request #20 from xmindlab-heicode/fix/secret-lifecycle-purge-rotate-cleanup
fix(secret): 闭合 #4 secret 生命周期 — 账户停用全量清理 + 30天 purge + 显式轮换
2026-06-09 16:25:51 +08:00
chenchenandClaude Opus 4.8 79e0e8b7d1 feat(heicode): 新增 /api/heicode/available-models,客户端模型列表收口 (#25)
客户端需要 Manager 下发用户可用模型作唯一来源(里程碑04 收尾)。

- GET /api/heicode/available-models(挂 UserOrV2DeviceAuth,与 /api/heicode/self 同
  设备配对鉴权)。
- 服务端解析:user.Group → service.GetUserUsableGroups → model.GetGroupEnabledModels
  (与 GetUserModels 同源),去重排序。
- 客户端安全形状 {model_id, display_name, default}:绝不下发 channelId/base_url/
  api_key/provider_type/单价(buildAvailableModelItems 纯函数 + 测试断言不泄露)。
- default 标记取 defaultAgentModelID()。

可选字段 capabilities/context_window/cost_tier 暂不下发(HM 无可靠来源,避免臆造;
客户端按可选处理)。etag/updated_at 可作后续。

Fixes #25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:51:21 +08:00
7d56b54324 feat(agent): 部署上限按订阅档动态化,闭合 #8 个人5/团队8 (#21)
沿用订阅体系现成先例 MaxPurchasePerUser,给 SubscriptionPlan 加 MaxAgents:

- model: SubscriptionPlan.MaxAgents(0=回退全局默认)+ GetUserMaxAgents(取用户
  active 订阅档最高 MaxAgents,无则回退默认)。
- HeicodeDeployAgent: 部署上限改 model.GetUserMaxAgents(userID, 环境默认5)。
  团队8=管理员把团队档配成8;个人5=默认;代码不硬编码 tier。
- subscription 控制器: Create/Update 校验 MaxAgents>=0;Update updateMap 补 max_agents。
- 前端管理端套餐表单(plan-form/types/drawer)加「Agent 部署上限」字段 + zh i18n。

测试 model/subscription_max_agents_test.go 全过(无订阅/团队档/0回退/过期/多档取最高/非法用户)。
go build、go vet、前端 tsc 干净。

Fixes #8

Co-authored-by: chenchen <chenchen@xinghanlab.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:28:19 +08:00
af094dde27 feat(agent): 按部署 agent 聚合模型用量,闭合 #9 用量聚合 (#22)
#9 的审计脱敏半部已由 PR#11 完成。据评审报告(L95 usage/billing 结构完整、
L112 监控 mock metrics 属 AM)核实:HM 侧通用用量聚合并不缺,真正缺的是
agent 维度用量视图。

- model.SumAgentUsage(userId, tokenName, start, end):按 agent 铸币 token 名
  'agent:<deployment_id>' 聚合 consume 日志(quota/prompt/completion/调用数),
  COALESCE+COUNT/SUM 跨 SQLite/MySQL/PG。
- GET /api/heicode/agents/:deployment_id/usage:返回该 agent 用量 + quota_per_unit
  (raw,调用方换算,与 /api/heicode/self 同契约),支持 ?start=&end= 时间窗。
- 测试 model/agent_usage_test.go:聚合正确、排除他人/非消费/不同 token、时间窗、空名。

go build/vet 干净;测试 PASS。

Refs #9 (审计脱敏半部 PR#11 已完成;监控 mock metrics 属 AM 侧)

Co-authored-by: chenchen <chenchen@xinghanlab.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:27:48 +08:00
aa4041f604 fix(web): 钱包余额直读 /api/user/self(去 502) + 部署完成提示/动画 (#23)
两处 HM web 前端修复(此前已热部署到 VM 验证):

1. 钱包 502:wallet 不再经 /api/heicode-auth → mcp-server 读余额(那条会触发
   HM GetUser 同级权限检查 → 502)。HM 本就是 new-api,直接读用户自己的
   /api/user/self(GetSelf,无角色检查)。
2. 异步部署无反馈:deploy-agent-page 加快轮询(启动中 3s/稳定 30s)+ 检测
   Pending→running 弹「✅ 部署完成,已就绪」(失败弹错误)+「启动中」徽章转圈脉冲。

tsc 干净。

Co-authored-by: chenchen <chenchen@xinghanlab.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:27:19 +08:00
chenchenandClaude Opus 4.8 57f475b15a fix(secret): 闭合 #4 secret 生命周期 — 账户停用全量清理 + 30天 purge + 显式轮换
承接 PR#13(撤销时删 KV secret)的剩余生命周期:

- 账户停用/删除全量清理:新增 revokeUserResourceSecrets(userID),撤销该用户全部
  带 secret_ref 的资源绑定并 best-effort 软删 KV 凭证材料(DB 标 revoked 为权威,
  KV 故障只记日志不阻塞)。接入 4 个账户路径:ManageUser disable/delete、
  DeleteUser(管理员硬删)、DeleteSelf(自助删)。
- 30天 purge:新增 listDeletedSecrets(GET /deletedsecrets 分页)+ secretExpired
  纯函数 + purgeExpiredVaultSecrets;StartSecretPurgeTask 每日(可配)purge 软删
  ≥30天(可配)的 secret,master 节点执行,KV 未配置则 no-op,purge-protection
  下安全 no-op(403 容错)。env:HEICODE_SECRET_PURGE_ENABLED/RETENTION_DAYS/
  INTERVAL_HOURS/NAME_PREFIX。
- 轮换显式化:UpsertResourceSecret 已有 secret 时改用 rotateSecret(同名新版本)
  并审计日志,而非每次 putSecret。

测试:parseDeletedSecretsPage / secretExpired / lastPathSegment 纯函数 +
revokeUserResourceSecrets 在 KV 未配置下仍正确标记 revoked、不误伤他人/无密钥绑定。

Refs #2 (secret_store delete/rotate/purge 部分;Manager↔Swarm 契约属 Swarm 侧)
Fixes #4

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 00:55:09 +08:00
FastheiandGitHub 03b9b24e45 Merge pull request #18 from xmindlab-heicode/fix/async-agent-deploy
fix(agent): 部署 agent 改异步,避免 Azure 网关 504 + 回滚
2026-06-08 18:08:17 +08:00
FastheiandGitHub 206fee1f78 Merge pull request #14 from xmindlab-heicode/feat/heicode-self-quota-display-type
feat(heicode-self): /api/heicode/self 返回 raw quota + quota_per_unit(与网页台同源换算)
2026-06-08 17:52:46 +08:00
zsbgnw12andGitHub 9444cdb498 fix agent deploy caps
approved
2026-06-08 17:49:35 +08:00
FastheiandGitHub 932b429a5d Merge pull request #15 from xmindlab-heicode/docs/hm-only-doc-sync
docs: 清理旧 HM 编排文档并新增蜂群 deferred 跟踪(HM-only 仓 + 模板 Agent 模型)
2026-06-08 17:42:43 +08:00
chenchenandClaude Opus 4.8 349957360d fix(heicode-self): quota_display_type 补 CUSTOM + 统一大写 + 加 displayQuota 测试
按 Mem0ried 复审:
- quotaDisplayUnitLabel 补 QuotaDisplayTypeCustom 分支(原落 default→"USD",
  导致自定义币种站点 display_quota 算对但 type 误报 USD、客户端配错符号)。
- tokens 标签由小写 "tokens" 改为规范常量 TOKENS,displayQuota 的判断同步用常量,
  对外 quota_display_type 统一大写(与 USD/CNY/CUSTOM 一致,客户端按常量比较不再 mismatch)。
- 新增表驱动测试 heicode_self_test.go:displayQuota 四模式(USD 除 / CNY·CUSTOM 乘 /
  TOKENS 原值)+ per_unit<=0 防除零;quotaDisplayUnitLabel 四模式(含 CUSTOM 回归)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:34:03 +08:00
chenchenandClaude Opus 4.8 f6da9c541e fix(agent): 异步回填加 Pending 状态守卫,避免覆盖用户 stop/delete
按 Fasthei 复审意见修并发状态机边界:
- 成功/失败回填都加 WHERE deployment_id=? AND LOWER(status)='pending',
  让 Pending->running / Pending->failed 成为 own-or-nothing 转换。
- 成功但 0 行(用户在 AM 启动窗口内 stop/delete/cancel):重读记录记日志,
  删除 AM 刚起的 orphan runtime,绝不把记录改回 running。
- 失败仅在我方拥有 Pending->failed 转换时才 revoke model token;用户已 stop/delete
  时交给对应路径(delete 已 revoke、stop 有意保留 key),避免重复/遗漏。
- 补竞态测试 StoppedDuringStart_NoResurrect:AM 启动被 hold 到记录置 stopped 后才返回,
  断言记录保持 stopped、不回填 runtime_id、orphan runtime 被删除。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:17:40 +08:00
chenchenandClaude Opus 4.8 da854277d3 test(agent): 异步部署的确定性测试(不阻塞 + Pending→running / →failed)
- 慢 AM mock 验证 startTemplateAgentAsync 立即返回(不阻塞 30s)。
- Pending 记录在 AM 响应后自动回填 running + runtime_id + subdomain。
- AM 失败时记录标 failed + failure_reason,不卡在 Pending。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:48:29 +08:00
chenchenandClaude Opus 4.8 a1a5b6037b fix(agent): 部署 agent 改异步,避免网关 504 + 回滚
POST /api/heicode/agents 原本在请求里同步阻塞 ~30s 等 AM 起 agent。Azure 网关
~20s 超时 → 504 → 请求 context 被取消 → AM 调用中止 → 部署回滚(agent 建不出)。

改为:先把 agent 存为 Pending 立即返回;AM 启动放到 context.Background() 的后台
goroutine(脱离请求 context),成功回填 subdomain/runtime_id/status,失败标记
status=failed 并吊销已铸的模型 key。客户端经已合并的列表刷新看 Pending→running。

go build ./... 通过;controller vet 干净。

影响面:仅 Manager(HM) 部署路径。客户端契约:deploy 现在立即返回 Pending(原为
阻塞后 running 或 504);AM 失败在列表里表现为 status=failed(原为同步
RUNTIME_UNAVAILABLE)。客户端本就轮询列表等 running。不改计费/密钥/审计。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:43:46 +08:00
zsbgnw12andGitHub b3d864b29b fix(agent): refresh list status from AM
Refresh non-terminal agent statuses before returning /api/heicode/agents.
2026-06-08 16:22:21 +08:00
chenchenandClaude Opus 4.8 ce733defcb feat(heicode-self): complete quota contract for all display types (#14 review)
Returns raw quota + the full conversion params (quota_per_unit, quota_display_type,
usd_exchange_rate) AND server-computed display_quota / display_used_quota, so the
desktop client renders correctly in USD / CNY / tokens without a ×500000 hack or a
missing-exchange-rate bug. Contract of record (server applies it):
  USD    : raw / quota_per_unit
  CNY    : raw / quota_per_unit * usd_exchange_rate
  tokens : raw (no division)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:36:55 +08:00
chenchenandClaude Opus 4.8 09331752f2 docs: address review — add swarm-deferred tracking entry + migration mapping
Per Fasthei's CHANGES_REQUESTED on #15: deleting all sub/swarm docs left no
tracking entry for the still-in-development swarm capability. Adds
docs/integration/heicode-swarm-deferred.md stating HM does not implement swarm
runtime, the old HM-orchestrated sub/swarm model is deprecated, new swarm work
lives on AM/Swarm side, and a per-file migration mapping (deprecated / deferred
/ superseded) for all 11 deleted docs. Links it from docs/README.md so deletion
no longer drops context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:19:17 +08:00
chenchenandClaude Opus 4.8 11f399826c docs: 删除已废弃的蜂群 / 旧计划文档
蜂群(swarm)旧版已弃、新版未出 → 删全部蜂群文档;附带删除旧执行/改造计划与旧项目交接稿(均为已废弃旧产物,由 HM owner 确认)。docs/README 当前索引不引用它们,无死链。

删除:
- docs/heicode-manager-sub-swarm-progress-checklist.md
- docs/heicode-manager-swarm-gap-analysis.md
- docs/heicode-manager-standalone-execution-plan.md
- docs/integration/agent-manager-swarm-runtime-change-request.md
- docs/integration/heicode-manager-swarm-runtime-env-template.md
- docs/integration/AgentManager蜂群Runtime接口实现要求.md
- docs/integration/AgentManager蜂群Runtime联调待确认与补充要求.md
- docs/integration/蜂群模式-AgentManager对接任务清单.md
- docs/Heicode-Manager-agent统一改造落地计划.md
- Heicode-Manager-项目说明与踩坑交接.md

影响面:仅文档。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:29:06 +08:00
chenchenandClaude Opus 4.8 c9767eb6bb docs: 同步文档到当前实际(HM-only 仓 + 模板 Agent 模型)
- 根 README/CLAUDE/AGENTS:本仓已从 monorepo 拆分,只剩 Heicode Manager(heicode/ Go 网关 + docs/)。重写仓库地图为 HM-only;客户端指向 heicode-{mac,win}os-release-dev 独立仓;移除指向已删文档的死链(vision/milestones/agent-platform-api-design/cc-haha-AGENTS);开发闭环改为 heicode/。
- 删除 docs/integration/agent-platform-request-contract.md(已被 AM 契约取代);docs/README 索引去掉该条。
- product-package 03/12:执行闭环去掉「Heicode 生成/判断子环节」旧编排说法,改为客户端直连 agent、agent 自驱、模型走 HM /v1。

影响面:仅文档。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:03:48 +08:00
chenchenandClaude Opus 4.8 9027f461c4 refactor(heicode-self): 返回 raw quota + quota_per_unit(与网页台同源换算)
按 HM owner 要求:客户端换算余额应与网页台完全一致。网页台用
raw_quota / quota_per_unit(web renderQuotaCompat,quotaPerUnit 默认 500000、
系统设置可配)。原 /api/heicode/self 预先除好(quotaToDisplayUnit)反而与网页台
不一致、并迫使客户端 ×500000 回推。

改为返回原始 quota/used_quota + quota_per_unit + quota_display_type,
客户端直接 quota / quota_per_unit 即可,和网页台同一公式,去掉所有缩放 hack。

移除不再使用的 quotaToDisplayUnit。go build/vet 通过。

影响面:仅 Manager(HM),服务桌面客户端余额展示(响应字段语义调整:quota 由
展示值改为原始值,新增 quota_per_unit)。需客户端同步按 quota/quota_per_unit 换算。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:26:52 +08:00
chenchenandClaude Opus 4.8 4476851286 feat(heicode-self): GET /api/heicode/self 返回 quota_display_type
桌面客户端余额查询应统一走 /api/heicode/self,其 quota/used_quota 已由
quotaToDisplayUnit 换算成展示单位(默认 USD = raw/QuotaPerUnit)。但响应未
告知客户端当前是哪种单位(USD/CNY/tokens),客户端易再除 500000 导致量级出错。

新增 quota_display_type 字段("USD" / "CNY" / "tokens"),客户端据此直接展示
quota、不再二次换算。

影响面:仅 Manager(HM),对客户端余额展示。新增响应字段、向后兼容;不影响
计费/密钥/审计/Swarm/AM。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 01:10:22 +08:00
90 changed files with 7012 additions and 4461 deletions
+28 -58
View File
@@ -1,83 +1,53 @@
# AGENTS.md — Heicode 单仓导航(给 Codex / 助手)
# AGENTS.md — Heicode Manager 单仓导航(给 Codex / 助手)
本文是 **仓库根级** 的快速地图与协作约定。细分栈的规则请看各子目录自带文档(避免重复与漂移)。
本文是 **仓库根级** 的快速地图与协作约定。细分栈规则看子目录自带文档(避免重复与漂移)。
## 仓库地图(explore 摘要)
> **本仓 = Heicode Manager(HM)端。** 历史上客户端(`cc-haha`)、官网(`website`)与 Manager 曾在同一 monorepo;现已拆分,**本仓只保留 Heicode Manager**。客户端(终端 + 桌面)在 `heicode-macos-release-dev` / `heicode-winos-release-dev` 独立仓,不在本仓。
## 仓库地图
| 路径 | 角色 | 栈 / 备注 |
|------|------|-----------|
| `cc-haha/` | **Heicode** 客户端:CLI(Ink)+ 本地 HTTP/WS 服务 + **Desktop**(Tauri + React) | Bun + TypeScript;产品入口 `bin/heicode` |
| `heicode/` | **Heicode Manager**:网关 + 管理控制台 | Go(Gin/GORM)+ `web/default` 前端(Bun/Rsbuild/React) |
| `website/` | 产品介绍站点 | Next.js;根 `docker-compose.yml` 提供 `heicode-www` :8888 |
| `docs/` | 愿景、**里程碑**(`docs/milestones/`)、**Agent 集成**(`docs/integration/`) | Markdown |
根 `package.json` 仅少量 workspace 级依赖(如适配器用到的包);**主要开发依赖在 `cc-haha/package.json`**。
| `heicode/` | **Heicode Manager**:基于 new-api 的模型网关 + 管理控制台 | Go(Gin/GORM)后端 + `web/default` 前端(Bun/Rsbuild/React) |
| `docs/` | 产品共识 / 实施计划 / 集成契约 | Markdown,索引见 [`docs/README.md`](./docs/README.md) |
## 权威子文档(改代码前先打开对应一篇)
- **客户端(cc-haha)**:[`cc-haha/AGENTS.md`](./cc-haha/AGENTS.md) — 目录结构、运行命令、测试、提交约定。
- **网关(heicode)**:[`heicode/AGENTS.md`](./heicode/AGENTS.md) — Go 分层、JSON/i18n/DB 规则等。
- **产品愿景与交付节奏**:[`docs/vision-heicode-full-stack-agentic-dev.md`](./docs/vision-heicode-full-stack-agentic-dev.md)、[`docs/milestones/README.md`](./docs/milestones/README.md)。
- **网关后端(`heicode/`,Go)**:[`heicode/AGENTS.md`](./heicode/AGENTS.md) — Go 分层、JSON / i18n / DB 规则、计费表达式等。
- **产品共识与实施计划**:[`docs/heicode.md`](./docs/heicode.md)、[`docs/plan.md`](./docs/plan.md)。
- **集成契约**:[`docs/integration/`](./docs/integration/) —— 当前模型「模板 Agent」见 `heicode-hm-template-agent-model.md`;HM↔AM 见 `heicode-am-contract.md`;桌面客户端对接见 `heicode-desktop-client-api.md`。
- **客户端**:在 `heicode-macos-release-dev` / `heicode-winos-release-dev` 独立仓,不在本仓。
## 最小开发闭环
```bash
# 依赖(客户端主体)
cd cc-haha && bun install
Heicode Manager = `heicode/`(Go 后端 + `web/default` 前端)。构建 / 运行命令与分层规则以 [`heicode/AGENTS.md`](./heicode/AGENTS.md) 与 `heicode/README.md` 为准。
# 终端 A:本地 API(桌面端依赖)
bun run src/server/index.ts
## 关键触摸点(代码索引)
# 终端 B:桌面
cd desktop && bun run tauri dev
```
联调本机 Manager(heicode)时常见:
```bash
HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts
```
更多变量见 `cc-haha/src/server/config/providerPresets.ts` 与 `cc-haha/src/server/api/heicode-auth.ts`(如 `HEICODE_<PROVIDER>_OAUTH_*`)。
## Heicode ↔ Manager 关键触摸点(代码索引)
- **客户端登录 / Provider**:`cc-haha/src/server/api/heicode-auth.ts`,路由前缀 `/api/heicode-auth/*`(由 `cc-haha/src/server/router.ts` 挂载)。
- **模型发现**:`cc-haha/src/server/services/providerService.ts`、`cc-haha/src/server/api/providers.ts`。
- **Provider 预设**:`cc-haha/src/server/config/providerPresets.json` + `providerPresets.ts`。
- **Manager 侧 Heicode OAuth**:`heicode/controller/heicode_oauth.go`,路由 `heicode/router/heicode-router.go`(例如 `/heicode/oauth/authorize`、`/heicode/oauth/session`)。
- **客户端登录**:客户端走 HM 的 `/api/heicode-auth/*`(转发到登录服务)+ V2 设备签名;详见 `docs/integration/heicode-desktop-client-api.md`。
- **Manager 侧 Heicode OAuth**:`heicode/controller/heicode_oauth.go`,路由 `heicode/router/heicode-router.go`(如 `/heicode/oauth/authorize`、`/heicode/oauth/session`)。
- **HM↔AM(agent 部署)**:`heicode/controller/agent_template_runtime.go`、`agent_template_handlers.go`;契约见 `docs/integration/heicode-am-contract.md`。
## 协作约定(根级)
1. **改哪一层跟哪篇文档**:Go 行为以 `heicode/AGENTS.md` 为准;客户端 TS/React 以 `cc-haha/AGENTS.md` 为准。
2. **小步提交**:沿用历史风格(如 `feat:` / `fix:` / `docs:`);PR 写清影响面与验证步骤。
3. **集成与「SK」边界**:平台契约见 [`docs/integration/agent-platform-api-design.md`](./docs/integration/agent-platform-api-design.md),里程碑见 `docs/milestones/`。
4. **不要臆测计费**:计费与订阅在平台侧,不在 Heicode 客户端内实现。
1. **改哪一层跟哪篇文档**:Go 行为以 `heicode/AGENTS.md` 为准;产品 / 架构以 `docs/heicode.md` + `docs/plan.md` 为准。
2. **小步提交**:沿用历史风格(`feat:` / `fix:` / `docs:`);PR 写清影响面与验证步骤。
3. **不要臆测计费**:计费在平台侧(new-api 计量),按 `docs/` 契约对接,不在本仓臆造扣费逻辑。
## Docker / 站点
## 当前线上入口
- 根目录 [`docker-compose.yml`](./docker-compose.yml):构建 `website/` 静态站点镜像(端口 **8888**)。
- Manager 本地编排以 `heicode/docker-compose.yml` 及仓库内 override 为准(若存在)。
- **VM / 生产同步代码**:优先在目标机上 **`git clone` / `git pull`** 与本仓库远程一致;**不要**用 `scp` 传整份源码或 compose(仅在仓库不可用或紧急热修时例外)。
- **VM 部署后清镜像**:每次 `docker compose ... up -d --build` 后执行 **`sudo docker image prune -f`**,清掉重建产生的悬空层;定期可 **`sudo docker system prune -f`**(不删仍在使用的卷)。避免只叠新镜像、旧层占满磁盘。
## 当前线上入口(2026-04)
- **Heicode 官网(Azure Static Web Apps)**:`https://ashy-dune-0e22d7b00.7.azurestaticapps.net`
- **Heicode Manager(生产)**:`https://code.xinghanlab.com/`
官网中的主按钮(登录/开始使用/CTA)默认应跳转到 `https://code.xinghanlab.com/`,避免出现历史 IP 地址。
## Manager 生产拓扑(Azure)
- 应用层:Azure VM 上运行 `heicode`(当前用 Docker 容器承载应用进程)。
- 数据库与缓存:使用 Azure 托管服务(不在 VM 上跑 PostgreSQL/Redis 容器)
- PostgreSQL: `heicode.postgres.database.azure.com` / DB `heicode`
- Redis: `heicode.redis.cache.windows.net:6380`(TLS)
- 容器落盘:VM 仅承载应用进程;**容器内产生的文件**(如 `/data` 上传与持久文件、`/app/logs`)通过 **bind mount** 写到 VM 本地磁盘,便于备份与重建容器后不丢。编排见 `heicode/docker-compose.azure-vm.yml`,目录由环境变量 `HEICODE_DATA_ROOT` 指定。
- 说明:不要在生产 VM 上再启本地 `postgres`/`redis` 容器并指向业务库,避免与托管实例混淆。
- 镜像清理:发版构建后删除悬空/旧镜像层(见上文「VM 部署后清镜像」),防止 OS 盘被 Docker 占满。
- 应用层:Azure VM 上运行 `heicode`(Docker 容器承载应用进程)。
- 数据库与缓存:Azure 托管服务(不在 VM 上跑 PostgreSQL / Redis 容器)
- PostgreSQL:`heicode.postgres.database.azure.com` / DB `heicode`
- Redis:`heicode.redis.cache.windows.net:6380`(TLS)
- 容器落盘:容器内产生的文件(`/data` 上传与持久文件、`/app/logs`)通过 **bind mount** 写到 VM 本地磁盘。编排见 `heicode/docker-compose.azure-vm.yml`,目录由 `HEICODE_DATA_ROOT` 指定。
- **VM 部署后清镜像**:每次 `docker compose ... up -d --build` 后执行 `sudo docker image prune -f`;定期 `sudo docker system prune -f`(不删在用卷),避免旧层占满磁盘。
- **VM / 生产同步代码**:优先在目标机 `git clone` / `git pull` 与远程一致;不要用 `scp` 传整份源码(紧急热修例外)。
---
*若本文件与子目录 `AGENTS.md` / `AGENTS.md` 冲突,以子目录为准并及时更新根文件摘要。*
*若本文件与 `heicode/AGENTS.md` 或 `docs/` 冲突,以子目录 / `docs` 为准并及时更新本文件。*
+28 -58
View File
@@ -1,83 +1,53 @@
# CLAUDE.md — Heicode 单仓导航(给 Claude Code / 助手)
# CLAUDE.md — Heicode Manager 单仓导航(给 Claude Code / 助手)
本文是 **仓库根级** 的快速地图与协作约定。细分栈的规则请看各子目录自带文档(避免重复与漂移)。
本文是 **仓库根级** 的快速地图与协作约定。细分栈规则看子目录自带文档(避免重复与漂移)。
## 仓库地图(explore 摘要)
> **本仓 = Heicode Manager(HM)端。** 历史上客户端(`cc-haha`)、官网(`website`)与 Manager 曾在同一 monorepo;现已拆分,**本仓只保留 Heicode Manager**。客户端(终端 + 桌面)在 `heicode-macos-release-dev` / `heicode-winos-release-dev` 独立仓,不在本仓。
## 仓库地图
| 路径 | 角色 | 栈 / 备注 |
|------|------|-----------|
| `cc-haha/` | **Heicode** 客户端:CLI(Ink)+ 本地 HTTP/WS 服务 + **Desktop**(Tauri + React) | Bun + TypeScript;产品入口 `bin/heicode` |
| `heicode/` | **Heicode Manager**:网关 + 管理控制台 | Go(Gin/GORM)+ `web/default` 前端(Bun/Rsbuild/React) |
| `website/` | 产品介绍站点 | Next.js;根 `docker-compose.yml` 提供 `heicode-www` :8888 |
| `docs/` | 愿景、**里程碑**(`docs/milestones/`)、**Agent 集成**(`docs/integration/`) | Markdown |
根 `package.json` 仅少量 workspace 级依赖(如适配器用到的包);**主要开发依赖在 `cc-haha/package.json`**。
| `heicode/` | **Heicode Manager**:基于 new-api 的模型网关 + 管理控制台 | Go(Gin/GORM)后端 + `web/default` 前端(Bun/Rsbuild/React) |
| `docs/` | 产品共识 / 实施计划 / 集成契约 | Markdown,索引见 [`docs/README.md`](./docs/README.md) |
## 权威子文档(改代码前先打开对应一篇)
- **客户端(cc-haha)**:[`cc-haha/AGENTS.md`](./cc-haha/AGENTS.md) — 目录结构、运行命令、测试、提交约定。
- **网关(heicode)**:[`heicode/CLAUDE.md`](./heicode/CLAUDE.md) — Go 分层、JSON/i18n/DB 规则等。
- **产品愿景与交付节奏**:[`docs/vision-heicode-full-stack-agentic-dev.md`](./docs/vision-heicode-full-stack-agentic-dev.md)、[`docs/milestones/README.md`](./docs/milestones/README.md)。
- **网关后端(`heicode/`,Go)**:[`heicode/CLAUDE.md`](./heicode/CLAUDE.md) — Go 分层、JSON / i18n / DB 规则、计费表达式等。
- **产品共识与实施计划**:[`docs/heicode.md`](./docs/heicode.md)、[`docs/plan.md`](./docs/plan.md)。
- **集成契约**:[`docs/integration/`](./docs/integration/) —— 当前模型「模板 Agent」见 `heicode-hm-template-agent-model.md`;HM↔AM 见 `heicode-am-contract.md`;桌面客户端对接见 `heicode-desktop-client-api.md`。
- **客户端**:在 `heicode-macos-release-dev` / `heicode-winos-release-dev` 独立仓,不在本仓。
## 最小开发闭环
```bash
# 依赖(客户端主体)
cd cc-haha && bun install
Heicode Manager = `heicode/`(Go 后端 + `web/default` 前端)。构建 / 运行命令与分层规则以 [`heicode/CLAUDE.md`](./heicode/CLAUDE.md) 与 `heicode/README.md` 为准。
# 终端 A:本地 API(桌面端依赖)
bun run src/server/index.ts
## 关键触摸点(代码索引)
# 终端 B:桌面
cd desktop && bun run tauri dev
```
联调本机 Manager(heicode)时常见:
```bash
HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts
```
更多变量见 `cc-haha/src/server/config/providerPresets.ts` 与 `cc-haha/src/server/api/heicode-auth.ts`(如 `HEICODE_<PROVIDER>_OAUTH_*`)。
## Heicode ↔ Manager 关键触摸点(代码索引)
- **客户端登录 / Provider**:`cc-haha/src/server/api/heicode-auth.ts`,路由前缀 `/api/heicode-auth/*`(由 `cc-haha/src/server/router.ts` 挂载)。
- **模型发现**:`cc-haha/src/server/services/providerService.ts`、`cc-haha/src/server/api/providers.ts`。
- **Provider 预设**:`cc-haha/src/server/config/providerPresets.json` + `providerPresets.ts`。
- **Manager 侧 Heicode OAuth**:`heicode/controller/heicode_oauth.go`,路由 `heicode/router/heicode-router.go`(例如 `/heicode/oauth/authorize`、`/heicode/oauth/session`)。
- **客户端登录**:客户端走 HM 的 `/api/heicode-auth/*`(转发到登录服务)+ V2 设备签名;详见 `docs/integration/heicode-desktop-client-api.md`。
- **Manager 侧 Heicode OAuth**:`heicode/controller/heicode_oauth.go`,路由 `heicode/router/heicode-router.go`(如 `/heicode/oauth/authorize`、`/heicode/oauth/session`)。
- **HM↔AM(agent 部署)**:`heicode/controller/agent_template_runtime.go`、`agent_template_handlers.go`;契约见 `docs/integration/heicode-am-contract.md`。
## 协作约定(根级)
1. **改哪一层跟哪篇文档**:Go 行为以 `heicode/CLAUDE.md` 为准;客户端 TS/React 以 `cc-haha/AGENTS.md` 为准。
2. **小步提交**:沿用历史风格(如 `feat:` / `fix:` / `docs:`);PR 写清影响面与验证步骤。
3. **集成与「SK」边界**:平台契约见 [`docs/integration/agent-platform-api-design.md`](./docs/integration/agent-platform-api-design.md),里程碑见 `docs/milestones/`。
4. **不要臆测计费**:计费与订阅在平台侧,不在 Heicode 客户端内实现。
1. **改哪一层跟哪篇文档**:Go 行为以 `heicode/CLAUDE.md` 为准;产品 / 架构以 `docs/heicode.md` + `docs/plan.md` 为准。
2. **小步提交**:沿用历史风格(`feat:` / `fix:` / `docs:`);PR 写清影响面与验证步骤。
3. **不要臆测计费**:计费在平台侧(new-api 计量),按 `docs/` 契约对接,不在本仓臆造扣费逻辑。
## Docker / 站点
## 当前线上入口
- 根目录 [`docker-compose.yml`](./docker-compose.yml):构建 `website/` 静态站点镜像(端口 **8888**)。
- Manager 本地编排以 `heicode/docker-compose.yml` 及仓库内 override 为准(若存在)。
- **VM / 生产同步代码**:优先在目标机上 **`git clone` / `git pull`** 与本仓库远程一致;**不要**用 `scp` 传整份源码或 compose(仅在仓库不可用或紧急热修时例外)。
- **VM 部署后清镜像**:每次 `docker compose ... up -d --build` 后执行 **`sudo docker image prune -f`**,清掉重建产生的悬空层;定期可 **`sudo docker system prune -f`**(不删仍在使用的卷)。避免只叠新镜像、旧层占满磁盘。
## 当前线上入口(2026-04)
- **Heicode 官网(Azure Static Web Apps)**:`https://ashy-dune-0e22d7b00.7.azurestaticapps.net`
- **Heicode Manager(生产)**:`https://code.xinghanlab.com/`
官网中的主按钮(登录/开始使用/CTA)默认应跳转到 `https://code.xinghanlab.com/`,避免出现历史 IP 地址。
## Manager 生产拓扑(Azure)
- 应用层:Azure VM 上运行 `heicode`(当前用 Docker 容器承载应用进程)。
- 数据库与缓存:使用 Azure 托管服务(不在 VM 上跑 PostgreSQL/Redis 容器)
- PostgreSQL: `heicode.postgres.database.azure.com` / DB `heicode`
- Redis: `heicode.redis.cache.windows.net:6380`(TLS)
- 容器落盘:VM 仅承载应用进程;**容器内产生的文件**(如 `/data` 上传与持久文件、`/app/logs`)通过 **bind mount** 写到 VM 本地磁盘,便于备份与重建容器后不丢。编排见 `heicode/docker-compose.azure-vm.yml`,目录由环境变量 `HEICODE_DATA_ROOT` 指定。
- 说明:不要在生产 VM 上再启本地 `postgres`/`redis` 容器并指向业务库,避免与托管实例混淆。
- 镜像清理:发版构建后删除悬空/旧镜像层(见上文「VM 部署后清镜像」),防止 OS 盘被 Docker 占满。
- 应用层:Azure VM 上运行 `heicode`(Docker 容器承载应用进程)。
- 数据库与缓存:Azure 托管服务(不在 VM 上跑 PostgreSQL / Redis 容器)
- PostgreSQL:`heicode.postgres.database.azure.com` / DB `heicode`
- Redis:`heicode.redis.cache.windows.net:6380`(TLS)
- 容器落盘:容器内产生的文件(`/data` 上传与持久文件、`/app/logs`)通过 **bind mount** 写到 VM 本地磁盘。编排见 `heicode/docker-compose.azure-vm.yml`,目录由 `HEICODE_DATA_ROOT` 指定。
- **VM 部署后清镜像**:每次 `docker compose ... up -d --build` 后执行 `sudo docker image prune -f`;定期 `sudo docker system prune -f`(不删在用卷),避免旧层占满磁盘。
- **VM / 生产同步代码**:优先在目标机 `git clone` / `git pull` 与远程一致;不要用 `scp` 传整份源码(紧急热修例外)。
---
*若本文件与子目录 `AGENTS.md` / `CLAUDE.md` 冲突,以子目录为准并及时更新根文件摘要。*
*若本文件与 `heicode/CLAUDE.md` 或 `docs/` 冲突,以子目录 / `docs` 为准并及时更新本文件。*
@@ -180,6 +180,8 @@ Key Vault 最低权限:
## 7. 蜂群 Runtime 配置
> ⚠️ **2026-06-10 勘误(模型已更新)**:本节描述的「普通 sub + 蜂群两套模式 + `SWARM_RUNTIME_*` + `/api/swarms`」是**旧的「HM 主导编排」模型,已作废**。当前权威模型:HM **不实现 swarm runtime**;单 Agent 走「模板 Agent + AM」(见 `docs/integration/heicode-am-contract.md`),多 Agent 蜂群归 **`agent_swarm`**(产品名 HeiCode Swarm)仓,其编排为 Master-Agent(分解→派发→评审→汇总),契约见 `agent_swarm/docs/integration/runtime-contract.md`(待冻结,`agent_swarm#2`)。详见 `docs/integration/heicode-swarm-deferred.md`。下表 `SWARM_RUNTIME_*` 仅为**仍存在于 env 但当前关闭(`SWARM_RUNTIME_ENABLED=false`)**的历史开关,保留作记录,不代表当前接入形态。
蜂群模式和普通 sub 模式是两套部署、两套语义。Manager 当前环境里蜂群 Runtime 开关是关闭状态。
| 环境变量 | 当前状态 | 当前值 / 位置 | 说明 |
@@ -196,7 +198,7 @@ Key Vault 最低权限:
| 项目 | 地址 | 说明 |
|------|------|------|
| HeiCode-Swarm Orchestrator | `http://52.139.240.116:8000` | 蜂群项目独立 Runtime / Orchestrator |
| `agent_swarm`(HeiCode Swarm)Orchestrator | `http://52.139.240.116:8000` | 蜂群项目独立 Runtime / Orchestrator(HM 侧 deferred,未在 Manager 生产 env 启用) |
## 8. NewAPI / 模型网关配置
@@ -1,208 +0,0 @@
# Heicode Manager 项目说明与踩坑交接
更新时间:2026-06-01
用途:给下一位 AI / 工程师快速理解 Heicode Manager 的项目边界、生产拓扑、普通 sub、蜂群、NewAPI 和已踩过的坑。
## 1. 一句话说明
Heicode Manager 是 Heicode 的服务端控制面和网页管理台,负责用户登录、模型网关配置、用户/设备/模型/渠道管理、普通 sub 敏捷任务控制、Runtime callback 落库、artifact/timeline 展示,以及和桌面客户端、Agent Manager Runtime、NewAPI、Azure Key Vault 之间的编排。
它不是桌面客户端本体,也不是 Agent Runtime 本体,也不是 NewAPI 本体。
## 2. 仓库结构
| 路径 | 作用 | 说明 |
|------|------|------|
| `heicode/` | Manager 后端和默认前端 | Go + Gin/GORM,`web/default` 是当前网页端 |
| `cc-haha/` | Heicode 桌面客户端和本地服务 | Tauri + React + Bun,客户端到 Manager 的请求 body 会走加密/签名流程 |
| `docs/` | 项目文档、部署、集成、进度清单 | 后续交接优先看这里 |
| `docs/deployment/` | 生产部署和迁移文档 | VM、Azure、配置交接 |
| `docs/integration/` | Runtime、桌面客户端、蜂群、普通 sub 对接文档 | 联调时优先看 |
开发前先看:
| 范围 | 文档 |
|------|------|
| 根仓库规则 | `AGENTS.md` |
| Manager 规则 | `heicode/AGENTS.md` |
| 生产配置 | `docs/deployment/Heicode-Manager-生产配置与账号交接清单.md` |
| 普通 sub 桌面客户端对接 | `docs/integration/heicode-desktop-sub-agile-api.md` |
| 普通 sub Agent Manager 对接 | `docs/integration/普通sub敏捷模式-AgentManager对接任务清单.md` |
| 蜂群 Agent Manager 对接 | `docs/integration/蜂群模式-AgentManager对接任务清单.md` |
## 3. 生产拓扑
```text
用户/桌面客户端
-> https://code.xinghanlab.com
-> Heicode Manager Docker container on Azure VM
-> Azure PostgreSQL / Azure Redis
-> NewAPI model gateway
-> 普通 sub Agent Manager Runtime: http://20.212.121.126
-> 蜂群 Runtime / Orchestrator: http://52.139.240.116:8000
-> Azure Key Vault: https://heicode-kv.vault.azure.net
```
当前 Manager 部署在 Azure VM 上,容器名 `heicode`,端口 `3000:3000`。VM 上也能看到 `new-api`、`postgres`、`redis`、`heicode-openbao` 容器,但正式业务数据库和缓存应以 Azure 托管 PostgreSQL / Redis 配置为准,不要误用 VM 本地容器判断生产数据。
## 4. 普通 sub 和蜂群必须分开
这是最容易踩坑的点。
| 模式 | 含义 | Manager 当前配置 | Runtime |
|------|------|------------------|---------|
| 普通 sub 敏捷模式 | 桌面客户端把一个开发任务拆给若干子 Agent,按需求、设计、开发、测试、部署等阶段推进 | `AGENT_RUNTIME_ENABLED=true` | `http://20.212.121.126` |
| 蜂群模式 | HeiCode-Swarm 的多 Agent swarm run / task graph 模式 | `SWARM_RUNTIME_ENABLED=false` | `http://52.139.240.116:8000`,需单独启用和联调 |
不要因为两个接口都可能叫 `/api/swarms` 就把它们混成一个概念。普通 sub 是 Heicode 的任务组织方式;蜂群是独立 swarm runtime 形态。
## 5. 普通 sub 当前主流程
```text
桌面客户端创建/补充任务
-> Manager 用户态接口生成 deployment draft
-> Manager 调 Agent Manager Runtime POST /api/swarms
-> Runtime 创建 run 并启动子 Agent
-> Runtime 执行中 callback Manager
-> Manager 保存 timeline / events / logs / artifacts / usage
-> 桌面客户端从 Manager 查询展示
```
关键点:
1. Runtime 执行过程会回调状态、日志、timeline、用量。
2. 最终业务交付物不是直接塞在聊天文本里,而是通过 `artifact.created` 落库。
3. 客户端应先查 artifact 列表,再通过 content 接口下载完整产物。
4. 如果 `usage=0` 或只有失败摘要 artifact,不能算真实业务交付完成。
## 6. artifact 展示坑
桌面客户端曾出现“以下是作为 Frontend 角色...”这种内容,看起来像交付物,其实多半只是 Runtime 的摘要文本。
正常设计应该是:
| 层级 | 应展示什么 |
|------|------------|
| 聊天时间线 | 阶段进度、Agent 状态、摘要说明 |
| 交付产物卡片 | `artifact_id`、标题、类型、摘要、大小、hash、下载入口 |
| 完整代码/文件 | 通过 `GET /api/agent/user/deployments/{deployment_id}/artifacts/{artifact_id}/content` 下载 |
如果 artifact 里有 `azblob://...` 或 `runtime://...`,客户端不应该直接暴露云凭据或要求用户自己访问 Blob,而应通过 Manager / Runtime content 代理接口拿完整内容。
## 7. NewAPI / 模型调用边界
NewAPI 是模型网关和用量计费入口,不是 Heicode Manager 自己的模型执行器。
排查模型问题时要区分三条链路:
| 链路 | 调用方 | 常见问题 |
|------|--------|----------|
| 桌面普通聊天 | 桌面客户端 -> Manager/NewAPI | 用户 token、模型列表、渠道权限、body 加密 |
| 普通 sub Runtime Agent | Runtime 子 Agent -> NewAPI/Manager 模型网关 | Runtime 环境变量、模型名、base_url、请求路径、上游超时 |
| Manager 后台模型配置 | 管理员网页 -> Manager/NewAPI | 渠道配置、分组、可用模型、价格表达式 |
曾踩过的坑:
- 某个模型 503/504 时,不一定是客户端参数错,也可能是 Runtime 子 Agent 使用的模型、base_url 或请求格式不对。
- Runtime 需要回传 `newapi_request_id` 和非零 token usage,方便定位 NewAPI 日志。
- 一个模型失败时可以切换模型验证,但不能把失败摘要 artifact 当成业务完成。
## 8. Azure Key Vault / OpenBao 边界
当前正式方向是 Azure Key Vault,不是 OpenBao。
| 项目 | 结论 |
|------|------|
| Azure Key Vault | 正式长期密钥托管方案 |
| OpenBao | VM 上存在历史/兼容容器,不作为当前正式方案 |
| Managed Identity | Manager 访问 Key Vault 的推荐方式 |
| 当前已知问题 | 之前健康检查出现过 `Identity not found`,说明 VM 身份或 `AZURE_CLIENT_ID`/Vault 权限未配好 |
如果后续迁移到 Container Apps / AKS / App Service,不能只切域名。必须重新配置 Managed Identity、Key Vault 权限、环境变量、持久化、数据库/Redis 网络、Runtime callback 地址。
## 9. 登录、设备、模型列表问题排查
之前遇到过用户登录成功但设备看不到、模型列表拿不到的问题。排查顺序:
1. Manager 是否有该用户记录。
2. JWT/session 是否能通过 Manager 校验。
3. 设备注册/心跳是否入库。
4. 用户是否绑定 NewAPI channel/group/token。
5. NewAPI 返回是否 401/403/模型列表为空。
6. Mac/Windows 客户端请求是否走同一 base_url、同一加密/签名逻辑。
不要只看“客户端显示已登录”,已登录不代表模型、设备、NewAPI 绑定都完整。
## 10. 网页端 Manager 已做过的重点
已处理过的方向包括:
- 注册页国内邮箱提示。
- Manager 登录后菜单跳转问题。
- 任务总览 / deployments 页面部分英文文案中文化。
- 普通 sub 控制面、部署草稿、运行状态、timeline、events、logs、artifacts 展示。
- artifact content 获取链路文档。
- Azure Key Vault secret_ref 接入方向。
- PayPal 充值和 NewAPI 模型费用关系说明文档。
继续改网页端时必须真实点击验证,尤其是:
- 登录后左侧菜单。
- 模型、渠道、供应商、支付、部署、任务总览。
- 创建新运行弹窗/抽屉。
- 产物卡片和下载入口。
## 11. 部署和 git 规则
生产部署原则:
```text
本地修改
-> git commit
-> git push 到 heicode-mananger main
-> VM 上 git pull
-> docker compose ... up -d --build
-> sudo docker image prune -f
-> 真实接口/页面冒烟
```
注意:
- 不要用 `scp` 传整份源码到 VM。
- `HeiCode-issues.git` 已废弃,后续不用再更新。
- VM 构建后必须清理 Docker 镜像,避免磁盘被旧层占满。
- 不要提交 `.env`、密钥、token、数据库连接串。
## 12. 新 AI 接手建议顺序
1. 读 `AGENTS.md` 和 `heicode/AGENTS.md`。
2. 读 `docs/deployment/Heicode-Manager-生产配置与账号交接清单.md`。
3. 用 `git status` 确认是否有未提交变更,不要动无关文件。
4. 区分当前任务是普通 sub、蜂群、网页端、NewAPI、Azure 还是桌面客户端。
5. 先用接口确认真实状态,再下结论。
6. 涉及生产前先确认是否需要部署,部署后必须真实冒烟。
7. 涉及密码、token、连接串时只写配置名和获取位置,不写明文。
## 13. 已踩过的典型坑
| 坑 | 正确处理 |
|----|----------|
| 把普通 sub 和蜂群混在一起 | 两套模式、两套配置、两套联调清单 |
| artifact 摘要当完整交付 | 必须通过 content 接口拿完整产物 |
| Runtime 返回 completed 就算成功 | 还要看 usage、artifact、日志、是否失败摘要 |
| Key Vault health 报错只改代码 | 先查 Managed Identity 和 Vault 权限 |
| NewAPI 一个模型失败就判 Manager 错 | 查 Runtime 请求路径、模型名、request id、上游状态 |
| VM 上有 postgres/redis 容器就当生产库 | 以 `SQL_DSN`、`REDIS_CONN_STRING` 和 Azure 托管服务为准 |
| 修改网页后不点击验证 | 必须真实打开页面、点菜单、点按钮 |
| 把密钥写入 md 方便交接 | 只能写配置名、用途、位置,不能写明文 |
## 14. 当前后续重点
| 方向 | 后续任务 |
|------|----------|
| 普通 sub | 持续和桌面客户端联调真实开发任务,确认 artifact content 是完整业务产物 |
| 蜂群 | 如需启用,先配置 `SWARM_RUNTIME_*`,单独跑蜂群 E2E |
| Key Vault | 修复 Managed Identity / `AZURE_CLIENT_ID` / Key Vault 权限 |
| NewAPI | 保持 Runtime 回传 `newapi_request_id`、usage、成本信息 |
| 网页端 | 继续中文化、交互完善、真实点击测试 |
| 生产部署 | 每次部署后检查 `/api/status`、登录、deployments、Runtime health |
+14 -48
View File
@@ -4,72 +4,38 @@ Heicode 面向**多人协作、可追溯交付**的软件团队:把需求对
下面的目录表仅供工程查阅;**不代表对外产品承诺、路线图或你必须采用的集成方式。**
> **本仓 = Heicode Manager(HM)端。** 历史上终端/桌面客户端(`cc-haha`)、官网(`website`)与 Manager 曾同处一个 monorepo;现已拆分,**本仓只保留 Heicode Manager**(基于 new-api 的模型网关 + 管理控制台)。客户端(终端 + 桌面)在 `heicode-macos-release-dev` / `heicode-winos-release-dev` 独立仓。
## 这个仓库里有什么(工程布局)
| 目录 | 大致含义 |
|------|----------|
| `cc-haha/` | **Heicode**(终端与桌面客户端及本地服务;此为源码目录名)。 |
| `heicode/` | **Heicode Manager**(网关与管理控制台服务端;此为源码目录名)。 |
| `website/` | 产品介绍站点(Next.js;可 `pnpm dev` 或 Docker 预览)。 |
| `docs/` | 愿景与范式;**[`docs/milestones/`](./docs/milestones/README.md)** 交付里程碑;**[`docs/integration/`](./docs/integration/README.md)** Agent 等平台接口设计。 |
| `heicode/` | **Heicode Manager**:基于 new-api 的模型网关 + 管理控制台。Go(Gin/GORM)后端 + `web/default` 前端(Bun/Rsbuild/React)。 |
| `docs/` | 产品共识、实施计划与集成契约(索引见 [`docs/README.md`](./docs/README.md))。 |
> 客户端与官网不在本仓。
## 产品在解决什么问题
- **协作范式**:把瀑布 / 敏捷下的角色分工落到可复述的闸门与智能体承接边界(**方向与原则**见 `docs/vision-heicode-full-stack-agentic-dev.md`;编队明细在文档附录)。
- **协作范式**:把瀑布 / 敏捷下的角色分工落到可复述的闸门与智能体承接边界。
- **交付可追溯**:文档、沟通与变更尽量与版本、发布对齐,便于复盘与合规。
- **工程上**:同一仓库便于客户端与服务端**同步发版、统一回归**,减少「谁和谁版本对不上」的摩擦。
- **模型与执行分层**:HM 提供模型网关与权限 / 计费;云端 agent 由 agent_management(AM)部署,客户端拿到 agent 公网地址后**直连 agent 使用**。
## 详细愿景与范式
**[docs/vision-heicode-full-stack-agentic-dev.md](./docs/vision-heicode-full-stack-agentic-dev.md)**
产品定位与架构共识见 [`docs/heicode.md`](./docs/heicode.md),实施计划见 [`docs/plan.md`](./docs/plan.md)。
## 快速启动(开发联调)
```bash
# 根依赖(Bun monorepo 根目录)
bun install
Heicode Manager 在 `heicode/` 子目录:Go 后端 + `web/default` 前端。完整构建 / 运行命令与分层规则见 [`heicode/CLAUDE.md`](./heicode/CLAUDE.md) 与 `heicode/README.md`。
# Heicode 客户端本地服务(目录 cc-haha)
cd cc-haha
bun run src/server/index.ts
# 另开终端:桌面端
cd cc-haha/desktop
bun run tauri dev
```
联调网关时示例:
```bash
HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts
```
**Heicode Manager**(`heicode/`)中与 Heicode 登录相关的路由(最小集,以实际代码为准):
与 Heicode 登录相关的最小路由(以实际代码为准):
- `GET /heicode/oauth/authorize`
- `GET /heicode/oauth/session`
平台侧能力还包括模型发现(如 `GET /v1/models`)与 Anthropic Messages 兼容入口等,详见 **Heicode Manager**(`heicode/`)与 **Heicode 客户端**(`cc-haha/`)各自 README。
平台侧能力还包括模型发现(如 `GET /v1/models`)与 Anthropic Messages 兼容入口等。
## 官网(Next.js)
```bash
cd website && pnpm install && pnpm dev
```
若根目录提供 `docker compose`,可按 compose 说明构建预览镜像(以仓库内 `docker-compose.yml` 为准)。
## 发版与回归建议
- 同一版本标签发布客户端与网关镜像。
- 每次发版至少回归:**登录**、**模型拉取**、**对话请求**。
- 先在本地 Docker / 本地联调通过,再做外网域名与证书。
## 相关外部参考(概念)
- [oh-my-claudecode](https://ohmyclaudecode.com/) — Claude Code 类工具的高效实践参考。
- Agent 平台以实际部署环境与文档为准。
集成与对接契约见 [`docs/integration/`](./docs/integration/)(HM↔AM、桌面客户端对接等)。
## 许可证
各子项目许可证见各子目录内 `LICENSE`(例如 Heicode Manager / `heicode/` 侧常见为 AGPLv3)。
`heicode/`(Heicode Manager,基于 new-api)许可证见 `heicode/LICENSE`(常见为 AGPLv3)。
@@ -1,183 +0,0 @@
# Heicode Manager 统一改造落地计划(Manager 侧)
> 版本:v0.1(初稿)
> 日期:2026-06-01
> 负责范围:仅 `heicode-mananger`(Manager 控制面 + `web/default` 前端)
> 性质:内部落地计划,随实现推进持续更新。不代表对外承诺。
> **状态更新(2026-06-01):`agnet → agent` 已按"强制全量、不留兼容"完成。**
> 后端 Go(路由 `/api/agent/*`、env `AGENT_*`、结构体/函数/文件名)、前端(`agent-console`/`agent-hub`、`/api/agent` 调用、i18n)、DB(表 `agent_*`、列 `agent_id`,并加幂等迁移 `renameAgnetTablesToAgent` 保住老数据)、文档、compose/.env、脚本均已统一为 `agent`。
> `go build ./...` 与 `go test ./controller ./model ./router ./middleware` 全绿;前端 `tsc -b` 通过。
> 本文 §3 原描述的"保留 `agnet` 兼容别名/线缆令牌"策略已**作废**——实际采用一次性硬切。唯一保留 `agnet` 字样的是 `model/main.go` 的迁移源表名(迁移所必需)。
> **部署待办**:① VM `.env` 的 `AGNET_*` 改名 `AGENT_*`;② 启动时迁移自动 rename 老表(已就绪);③ 其他三仓库(agent_management / HeiCode-Swarm / 客户端)需同步切到 `/api/agent` 与新 env,否则跨服务对接在它们切换前会中断(按你的要求强制先行)。
## 0. 基准文档
本计划是以下两份的 **Manager 侧拆解执行版**,结论以原文为准:
| 来源 | 位置 | 作用 |
|---|---|---|
| 统一调用方案 v0.1 | gitee `taijibaga/heicodedebug` → `2026-06-01_heicode客户端相关定义.md` | 四仓库总纲:接口归口、模式拆分、状态裁决、产物、回调 |
| 全链路代码评审报告 | gitee `taijibaga/heicodedebug` → `2026-06-01_Heicode全链路代码评审报告.md` | P1–P7 问题与证据 |
| 桌面 sub 对接文档 | `docs/integration/heicode-desktop-sub-agile-api.md` | 现行普通 sub 契约 |
四仓库分工(总纲 §4):客户端只调 Manager;**Manager 是唯一接口入口 + Runtime 路由器 + 唯一状态裁判**;`agent_management` 执行 Sub Agile;`HeiCode-Swarm` 执行 Swarm。
## 1. 贯穿全局的原则(来自总纲)
1. `agnet` 是历史拼写错误,统一为 `agent`;旧接口兼容期只做转发。
2. Sub Agile 与 Swarm 是两套运行时,**不再复用 `/api/swarms` 表达两种模式**,按 `mode` 路由。
3. Runtime 只上报**结构化事实**;Manager 据结构化字段裁决 `display_status`;客户端只消费结论,**不再用正则自判产物**。
4. 安全红线:只传 `azkv://` 等 `secret_ref`,禁明文密钥;高危操作 Manager 先审批再下发。
## 2. 已完成(分支 `fix/manager-deliverable-and-secret-validation`,commit `12602eb`,已推送)
| 项 | 文件 | 内容 | 对应总纲 |
|---|---|---|---|
| P2 交付物判定 | `controller/agnet_runtime_client.go` | `runtimeArtifactsAreSummaryOnly` 改读结构化 `artifact_type`+文件信号,弃脆弱正则 | §10.6 |
| P6a secret_ref 校验 | `controller/resource.go` | Resource CRUD 强制 `azkv://` | §16/§17.6 |
| P6b 值级密钥扫描 | `controller/resource.go`、`controller/agnet_control_plane.go` | `containsPlaintextSecret`/`containsSensitiveGrantField` 扫字符串值(sk-/JWT/PEM) | §16 |
| P5 默认模型收敛 | `controller/agnet_role_template.go`、`controller/agnet_task_bridge.go` | 单一来源 `defaultAgnetModelID()`(env `AGNET_DEFAULT_MODEL_ID`,默认 `gpt-5.4`);移除 `agnet-model-<role>` 占位回退 | §9 |
| P3 状态枚举 | `docs/integration/heicode-desktop-sub-agile-api.md` | 补 `completed` 终态 + runtime_state 镜像 + 未知值兜底 | §10.4 |
| 单测 | `controller/agnet_deliverable_secret_test.go` | 覆盖以上行为 | — |
> 注:`defaultAgnetModelID` 在后续命名统一中应改名 `defaultAgentModelID`,env 改 `AGENT_DEFAULT_MODEL_ID`(保留 `AGNET_*` 回退)。
## 3. 命名统一 `agnet → agent`
### 3.1 爆炸半径(仅本仓库,不含另三仓库与跨团队契约)
**2155 处 / 63 文件**(`rg -i agnet`)。按「客户端/其他服务是否依赖」分类:
| 类别 | 规模 | 对外可见 | 处理策略 | 风险 |
|---|---|---|---|---|
| HTTP 路由 `/api/agnet/*` | 路由表 | ✅ 客户端+Runtime | 新增 `/api/agent/*` 别名,旧保留转发 | 🔴 |
| JSON 字段 | 仅 `agnet_id`(3 处,含 DB 列) | ✅ resource grant 契约 | 响应 dual-emit `agent_id`,请求 dual-accept | 🟠 |
| env 变量 `AGNET_*` | compose + 代码读取 | ✅ 部署配置 | 代码先读 `AGENT_*` 回退 `AGNET_*` | 🟠 |
| DB 表 `agnet_*` / `agnet_id` 列 | ~6 表 | ❌ 内部 | **用 GORM `TableName()` 钉住物理名不动**,物理改名作为最后单独迁移 | 🔴 |
| Go 内部标识(`AgnetXxx`、函数、文件名 `agnet_*.go`) | ~1800 | ❌ 编译期可查 | 纯重构,分模块小步改 | 🟢 |
| 前端(`agnet-console/`、组件、i18n key) | ~250 | ❌ 内部(调的是路由) | 重构,随路由切换 | 🟢 |
| 文档 | 多处 | — | 新文档写 `agent`,`agnet` 标 deprecated | 🟢 |
### 3.2 兼容期规则(总纲 §19)
1. 旧接口只做转发。2. 新文档只写新接口。3. 新客户端只调新接口。4. Manager 内部存储统一 `agent` 命名。5. 日志可记 legacy route,不展示给普通用户。
## 4. 路由拆分与归口(总纲 §5)
### 4.1 客户端 → Manager(新增)
```
/api/heicode/capabilities
/api/heicode/sub-agile/tasks/... (或统一 /api/heicode/tasks + body.mode)
/api/heicode/swarm/tasks/...
```
### 4.2 Manager → Runtime(新增,按 mode 分流)
```
/api/agent/sub-agile/deployments -> agent_management (AGENT_RUNTIME_* / 现 AGNET_RUNTIME_*)
/api/agent/swarm/deployments -> HeiCode-Swarm (SWARM_RUNTIME_*)
```
Manager 已具备双 env 前缀分流机制(`agnetRuntimeClientConfigForMode`);蜂群侧配置就绪只差 `SWARM_RUNTIME_SERVICE_TOKEN`。
### 4.3 回调(新增 + 旧转发)
```
新:POST /api/agent/callbacks/runtime-events
旧:POST /api/agnet/callbacks/swarm-events (兼容转发到同一 handler)
```
## 5. 状态裁决:Manager 成为唯一裁判(总纲 §10,关联评审 P1)
### 5.1 三层状态 + display_status
- `client_task_status`(客户端本地)/ `cloud_deployment_status`(Manager)/ `runtime_execution_status`(Runtime)
- Manager 输出唯一 `display_status` 给客户端。
### 5.2 Runtime 必须上报的结构化交付物事实(契约)
```json
"deliverable": {
"has_deliverable": true,
"summary_only": false,
"artifact_ids": ["art_xxx"],
"files_modified": ["src/app.ts"],
"has_diff": true,
"commit_sha": ""
}
```
### 5.3 Manager 裁决规则
| Runtime 事实 | display_status |
|---|---|
| `completed` + has_deliverable + !summary_only | `completed` |
| `completed` + summary_only / 无 artifact | `completed_without_deliverable` 或 `needs_codegen` |
| `failed` | `failed` / `stopped` / `waiting_approval` 同名透传 |
### 5.4 Manager 落点
- `controller/agnet_callback.go`(`applyAgnetCallbackDeploymentState` 消费 `deliverable`,算 verdict)
- `model/agnet_deployment.go`(加 `DeliveryVerdict` / display_status 列,三库兼容 `varchar`)
- `controller/agnet_control_plane.go`(detail/timeline 暴露字段)
- 复用已就绪的 `artifactIsSummaryOnly`(P2)+ usage
- **阶段化**:先加派生字段不改 `status`(向后兼容)→ 客户端跟进后再引入新终态值。
## 6. 模型策略(总纲 §9)
- Sub Agile:`per_role` / `default`;Swarm:`primary`。
- 单一来源默认模型(P5 已起步,待改名 `defaultAgentModelID`)。
- Manager 校验:模型存在 / 套餐允许 / 角色允许 / 预算 / Runtime 支持。
## 7. 项目文件夹产物 `project_folder`(总纲 §12)—— 中期大件
- artifact 升为两级:`Project Artifact` → `File Artifacts / Directory Entries`。
- 新 artifact_type:`project_folder` / `project_archive`(现有 `code_patch/document/...` 保留)。
- 新增接口:`.../artifacts/{id}/manifest`、`.../files/{path}`、`.../archive`。
- **本地修改回传 + revision 协议**(§12.7):`local-edits` / `batch` / 冲突 `ARTIFACT_REVISION_CONFLICT` / Manager 维护 current accepted revision。
- 影响:新 model(artifact revision / project entries)、新 controller、content 代理扩展。**工程量大,单独立项。**
## 8. 云部署生命周期(总纲 §18)—— 远期
`project_folder` → 选 target(Azure/阿里云/AWS) → Manager 校验/审批/凭证/预算 → Deploy Worker/Runtime 执行 → `deployment_manifest` artifact 回传。**远期,本轮不展开。**
## 9. 安全与审计(总纲 §16/§17)
| 项 | 现状 | 待办 |
|---|---|---|
| `secret_ref` 强制 `azkv://` | ✅ agnet 路径 + Resource CRUD(P6a) | 覆盖其余写入路径 |
| 值级密钥扫描 | ✅ P6b | — |
| 高危操作 Manager gate | ❌ 现为立即 `accepted`,阻断依赖 Runtime | Manager 侧加 risk_level/pending 审批 gate(关联 P1/§17.1) |
| 短期凭证 lease 真派生 | ❌ 占位(只发 `lease://`,不 mint) | 接 Key Vault 派生短期凭证 |
| revoke 同步 Runtime | ⚠️ approve 有 sync,revoke 无 | 补 revoke→Runtime 通知 |
| 审计 fail-closed | ⚠️ best-effort | 关键审计改 fail-closed 或告警 |
| 客户端禁 inline secret_ref | — | Manager 改为接受 `resource_binding_id`,内部映射 secret_ref(§17.6) |
## 10. 落地阶段(总纲 §20,Manager 承担)
| 阶段 | Manager 任务 | 自主性 | 依赖 | 状态 |
|---|---|---|---|---|
| **1 接口/命名** | `/api/agent/*` 别名、`/api/heicode/{sub-agile,swarm}/*`、callback 新路由、`AGENT_*` env 回退、`agnet_id`→`agent_id` 双字段 | ✅ 加法自主 | — | 待开始 |
| **2 模式路由** | 按 `mode` 路由两套 `*_RUNTIME_*` | ✅ | 蜂群 token | 机制已就绪 |
| **3 模型策略** | per_role/primary 校验 | ✅ | — | P5 起步 |
| **4 状态/产物裁决** | display_status + deliverable 字段 + project_folder + revision | 🟡 | Runtime 发结构化事实 | P1/P2 起步 |
| **5 日志/回调/调试** | user_logs/debug_logs 分层 + diagnostics + 统一 callback | ✅ | Runtime callback 切换 | diagnostics 已有雏形 |
## 11. 跨团队依赖(Manager 做不了,需协调)
1. **agent_management / HeiCode-Swarm**:上报结构化 `deliverable` 事实;接入 `/api/agent/{sub-agile,swarm}/*` 路由;callback 切 `/api/agent/callbacks/runtime-events`;产出真实 `project_folder`。
2. **蜂群 Runtime**:`SWARM_RUNTIME_SERVICE_TOKEN` 安全配置 + 修复 single-agent fallback(评审 P7)。
3. **客户端(macOS/Windows)**:切新接口、消费 display_status、project 文件树展示、本地 edit 上传、禁 mock/直连(§17)。
## 12. 风险与红线
- **`agnet` 是 load-bearing**(路由/env/DB/跨服务契约)——**严禁全局 sed**,必须加法别名 + 兼容期。
- DB 物理表/列名暂不改,用 `TableName()` 钉住,避免迁移风险。
- 跨服务契约字段改动一律 **dual-emit + dual-accept**,且需与调用方协调切换节奏。
- 状态裁决先加字段不覆盖 `status`,避免误伤「真完成但 Runtime 未回 artifact」的任务。
## 13. 待确认事项
1. 统一方案仍是 **v0.1**,落地前需与作者对齐版本/范围。
2. approval decision 回传路径:`/api/agent/{mode}/deployments/{id}/approvals/{approval_id}` 的最终形态。
3. 客户端→Manager 路由用「分模式」还是「统一 `/api/heicode/tasks` + body.mode」。
4. `needs_codegen` 与 `completed_without_deliverable` 的判定边界(何时用哪个)。
5. project_folder / 云部署的优先级与排期(是否本阶段做)。
---
*维护:本文件随实现进度更新;每完成一项在「已完成/状态」列标注 commit。*
+7 -5
View File
@@ -7,12 +7,14 @@
| [`heicode.md`](./heicode.md) | Heicode 当前产品定位、系统边界和架构共识 |
| [`plan.md`](./plan.md) | 按当前共识拆出的实施计划 |
| [`heicode-runtime-auth-newapi-secret-design.md`](./heicode-runtime-auth-newapi-secret-design.md) | 用户输入、登录用户复用、NewAPI 扣费映射、Azure Key Vault 凭证托管与短期凭证注入边界 |
| [`heicode-manager-sub-swarm-progress-checklist.md`](./heicode-manager-sub-swarm-progress-checklist.md) | Heicode Manager sub 模式、瀑布/敏捷、蜂群模式的已完成/未完成/依赖/风险/下一步进度清单 |
| [`heicode-manager-standalone-execution-plan.md`](./heicode-manager-standalone-execution-plan.md) | Heicode Manager 端可独立完成任务的执行计划、顺序、验收标准和边界 |
| [`integration/Heicode-登录接口对接文档.md`](./integration/Heicode-登录接口对接文档.md) | 已上线登录接口对接文档 |
| [`integration/agent-platform-request-contract.md`](./integration/agent-platform-request-contract.md) | Manager 请求 Agent 平台时携带的部署、日志、监控、事件与审计接口参数 |
| [`integration/heicode-hm-template-agent-model.md`](./integration/heicode-hm-template-agent-model.md) | **当前模型**:模板 Agent + 客户端直连,HM 端后端/前端改造说明 |
| [`integration/heicode-hm-legacy-teardown.md`](./integration/heicode-hm-legacy-teardown.md) | 旧 sub 任务编排模型的代码/表/前端删除清单 |
| [`integration/heicode-desktop-client-api.md`](./integration/heicode-desktop-client-api.md) | 桌面客户端对接 HM(模板 Agent 模型),已生产验证 |
| [`integration/heicode-am-contract.md`](./integration/heicode-am-contract.md) | HM ↔ AM(agent_management)接口契约 |
| [`integration/heicode-swarm-deferred.md`](./integration/heicode-swarm-deferred.md) | **蜂群(Swarm)现状裁定与后续跟踪入口**:HM 当前不实现 swarm runtime,旧 sub/蜂群文档作废后的上下文迁移映射与归属(AM/Swarm 侧) |
| [`integration/telemetry-production-enablement-checklist.md`](./integration/telemetry-production-enablement-checklist.md) | **客户端错误遥测生产开启前置 Checklist(#44 定稿)**:代码侧控制核验、生产配置、隐私/法务签字硬前置、上线/回滚验证;开启 `HEICODE_TELEMETRY_ENABLED` 前必过 |
| [`deployment/azure-production-deploy-guardrails.md`](./deployment/azure-production-deploy-guardrails.md) | Azure VM / PostgreSQL / Redis / Agent / NewAPI 生产部署前的安全守卫、环境变量注入和验证计划 |
旧 Agent API 草案、旧里程碑、旧架构说明和旧上手材料不再作为实施依据。后续文档和实现以 `heicode.md` 与 `plan.md` 为准;生产部署操作以安全守卫文档约束,且不得覆盖产品/架构主线。
旧 Agent API 草案、旧 sub / 蜂群任务编排、旧里程碑、旧架构说明和旧上手材料不再作为实施依据(相关文档已删除)。**蜂群相关文档的作废/迁移映射与「HM 不实现 swarm runtime、新能力归 AM/Swarm 侧跟踪」的结论见 [`integration/heicode-swarm-deferred.md`](./integration/heicode-swarm-deferred.md)(删除≠丢上下文,迁移到此追踪入口)。** 当前实施模型以 `heicode.md`、`plan.md` 与 `integration/heicode-hm-template-agent-model.md` 为准;生产部署操作以安全守卫文档约束,且不得覆盖产品/架构主线。
代码中的过渡期命名、旧接口注释或旧 UI 文案只作为现状参考;若与 `heicode.md` / `plan.md` 冲突,应先更新实现或另行补充当前主线文档,不得恢复旧 Agent/M1-M5 草案作为依据。
@@ -42,6 +42,8 @@ HEICODE_PUBLIC_BASE_URL=https://code.xinghanlab.com
### 2.2 Agent Manager / 普通 sub / 蜂群联调配置
> ⚠️ **2026-06-10 勘误**:下列「普通 sub / 蜂群 + `/api/swarms` + `SWARM_RUNTIME_*`」属**旧「HM 主导编排」模型,已作废**。当前权威模型:单 Agent 走「模板 Agent + AM」(`heicode-am-contract.md`);多 Agent 蜂群归 **`agent_swarm`**(HeiCode Swarm)仓(Master-Agent 编排),HM 侧 deferred,契约待 `agent_swarm#2` 冻结。见 `docs/integration/heicode-swarm-deferred.md`。本节 env 仅为历史记录(蜂群开关当前 `SWARM_RUNTIME_ENABLED=false`)。
```env
AGENT_RUNTIME_ENABLED=true
AGENT_RUNTIME_BASE_URL=http://20.212.121.126
@@ -1,413 +0,0 @@
# Heicode Manager 可独立执行任务计划
更新时间:2026-05-30
负责人范围:Heicode Manager 端
用途:后续开发按本文逐项执行、验收和更新状态。
## 一、核查结论
本文只列 Manager 端能独立完成的任务。判断标准是:不要求 Heicode 客户端新增功能、不要求蜂群 / Agent Runtime 提供真实接口、不要求 AKS / NATS / Prometheus 等基础设施先上线。
| 结论 | 说明 |
|---|---|
| 可以独立做 | Manager 自己的 DB 模型、Go API、前端页面、权限校验、回调接收骨架、artifact 数据模型、幂等、模拟事件和文档口径 |
| 不能独立做 | 真实 `swarm_id`、worker claim / heartbeat、真实 handoff / retry、真实 Runtime 日志指标、客户端审批弹窗、短期凭证注入 runtime、SK 工具真实调用结果 |
| 当前最大问题 | Manager 本地闭环已基本形成;剩余主要是等待真实 Runtime/客户端产生数据后联调验证 |
## 二、排除项
以下任务不放入 Manager 独立开发计划,避免把外部依赖误报为 Manager 可完成。
| 事项 | 排除原因 | 需要谁配合 |
|---|---|---|
| 真实创建 Swarm Run 并返回 `swarm_id` | 需要蜂群平台提供 `POST /api/swarms` 或等价生产接口 | 蜂群 / Agent Runtime |
| 子 Agent claim、heartbeat、release、timeout | 需要 worker runtime 和任务池 | 蜂群 / Agent Runtime |
| handoff、retry、blocked 的真实状态机 | 需要 Runtime 产生任务事件 | 蜂群 / Agent Runtime |
| 真实日志流和 CPU/内存/耗时指标 | 需要日志/指标源 | 蜂群 / 基础设施 |
| 客户端高危审批主弹窗 | 产品要求审批主体验在客户端 | Heicode 客户端 |
| 短期凭证真实注入子 Agent | 需要受控 runtime、身份和网络通道 | 蜂群 / 基础设施 |
| SK 工具真实调用结果 | 需要 SK 平台或 Runtime 上报 invocation event | 蜂群 / SK 平台 |
| 交付结果回到客户端 | 需要客户端展示和 Runtime artifact 输出 | 客户端 + 蜂群 |
## 三、执行顺序总览
| 顺序 | 任务 | 优先级 | 是否 Manager 独立 | 完成后价值 |
|---:|---|---|---|---|
| 1 | 增加 sub 模式字段 | P0 | 是 | 已完成:明确任务组织方式,支撑瀑布/敏捷状态展示 |
| 2 | 打通 HeicodeTask 到 Agent deployment 的 Manager 桥接 | P0 | 是 | 已完成:任务可生成 deployment draft |
| 3 | 增加用户态 Agent deployment API | P0 | 是 | 已完成:普通用户可以在自己资源范围内创建/查看/停止 deployment |
| 4 | 统一 `/api/swarms` 与 `/api/agent/deployments` 边界 | P0 | 是 | 已完成:为后续蜂群联调留稳定 adapter |
| 5 | 建 callback 接收端骨架 | P0 | 是 | 已完成:先接住事件、artifact、审批请求、timeline |
| 6 | 建 artifact 数据模型和 API | P0 | 是 | 已完成:交付物/产物摘要落库并可展示 |
| 7 | 增加回调幂等和签名/服务身份校验骨架 | P1 | 是 | 已完成:重复回调不重复写入,支持 token/HMAC |
| 8 | 持久化 SK snapshot | P1 | 是 | 已完成:容器重启后任务上下文和审计不丢 |
| 9 | 任务视角审计聚合 | P1 | 是 | 已完成:按 deployment 聚合 audit/callback/artifact/SK |
| 10 | 本地模拟蜂群事件冒烟入口 | P1 | 是 | 已完成:默认模拟 task/blocked/retry/handoff/artifact/approval/timeline |
| 11 | 前端页面补齐独立闭环展示 | P1 | 是 | 已完成:展示任务流、deployment、事件、artifact、审批和审计关系 |
| 12 | 文档口径清理 | P2 | 是 | 已完成:当前口径以 Azure Key Vault / secret_ref 为准 |
| 13 | AWS/GCP 占位提示 | P2 | 是 | 已完成:避免用户误以为 AWS/GCP 已可用 |
| 14 | 请求 body 加密策略确认 | P2 | 是 | 已完成:桌面端 sub POST 复用模型调用 V2 body 加密 |
| 15 | Runtime 状态诊断与兜底产物识别 | P1 | 是 | 已完成:Manager 可独立查询 Runtime 状态,页面明确区分普通 sub / 蜂群,并提示兜底摘要不是最终交付物 |
| 16 | Artifact 完整内容代理下载 | P1 | 是 | 已完成:Manager 校验用户和 artifact 后,通过 Runtime content 接口代理下载完整产物 |
## 四、任务明细
### 任务 1:增加 sub 模式字段
| 项 | 内容 |
|---|---|
| 目标 | Manager 能记录任务或部署计划采用 `waterfall` / `agile` 哪种组织方式 |
| 修改文件 | `heicode/model/agent_deployment.go`、`heicode/controller/agent_control_plane.go`、`heicode/web/default/src/features/agent-console/api.ts`、`heicode/web/default/src/features/agent-console/create-agent-deployment-sheet.tsx` |
| 建议字段 | `sub_mode`,枚举:`waterfall`、`agile`,默认 `agile` |
| 验收 | 创建 deployment 后 DB、API response、前端详情都能看到 `sub_mode` |
| 测试 | `go test ./controller -run 'TestAgent.*SubMode|TestAgentDeployment'`;`cd web/default && bun run typecheck` |
验收标准:
- 不允许写成自由文本。
- 旧数据无字段时默认按 `agile` 展示。
- 不能把 `sub_mode` 当成蜂群 Runtime 流程,只表示 Heicode 任务组织方式。
### 任务 2:HeicodeTask 到 Agent deployment 桥接
| 项 | 内容 |
|---|---|
| 目标 | Manager 能从 HeicodeTask 的任务卡生成本地 Agent deployment payload |
| 修改文件 | `heicode/controller/agent_control_plane.go`、新增 `heicode/controller/agent_task_bridge.go`、`heicode/web/default/src/lib/heicode-mcp.ts`、`heicode/web/default/src/features/tasks/task-card-view.tsx` |
| 新增 API | `POST /api/agent/tasks/:task_id/deployment-draft` 或等价 user-scoped endpoint |
| 输入 | task id、sub_mode、预算、资源范围、角色模板 |
| 输出 | deployment draft 或创建后的 `deployment_id` |
| 测试 | 新增 controller 单测;前端 typecheck |
验收标准:
- 任务能关联 `deployment_id` 或返回可提交的 deployment draft。
- draft 中不能包含明文密钥,只能出现 `secret_ref`。
- 找不到 task 或资源授权不足时返回明确错误。
### 任务 3:用户态 Agent deployment API
| 项 | 内容 |
|---|---|
| 目标 | 普通用户可以创建、查询、停止自己资源范围内的 deployment |
| 修改文件 | `heicode/router/api-router.go`、`heicode/controller/agent_control_plane.go`、`heicode/controller/agent_control_plane_test.go` |
| 当前问题 | `/api/agent/deployments` 走 `AdminAuth` |
| 新增建议 | 保留 admin route;新增 user route:`/api/agent/user/deployments` 或在同一路由中按 user scope 限制 |
| 测试 | 普通用户创建成功;越权查询别人 deployment 失败;停止别人 deployment 失败 |
验收标准:
- 用户只能看到自己的 deployment。
- `user_context.user_id` 为空时用登录用户 id 填充。
- 请求体伪造别人 `user_context.user_id` 必须被覆盖或拒绝。
- resource grant 必须属于当前用户。
### 任务 4:统一 `/api/swarms` 与 `/api/agent/deployments` 边界
| 项 | 内容 |
|---|---|
| 目标 | Manager 内部形成生产蜂群接口 adapter,不再让调用方混淆两个口径 |
| 修改文件 | 新增 `heicode/controller/agent_swarm_adapter.go` 或 `heicode/service/agent_swarm_adapter.go`,更新 `docs/integration/agent-platform-request-contract.md` |
| 当前现实 | 本地已有 `/api/agent/deployments`,蜂群资料包目标接口是 `/api/swarms` |
| 独立做法 | 先实现 Manager 内部 adapter 和统一 DTO,真实外呼先留配置开关,默认走本地 control-plane |
| 测试 | adapter 单测验证 payload 字段、`secret_ref`、correlation_id、sub_mode |
验收标准:
- 文档明确本地 control-plane 与生产 Runtime 的关系。
- 未来切换真实蜂群平台时,不需要重写前端页面。
- adapter 默认不外呼,避免误触发不存在的生产 Runtime。
### 任务 5:callback 接收端骨架
| 项 | 内容 |
|---|---|
| 目标 | Manager 先具备接收蜂群平台回调的 API 和落库能力 |
| 修改文件 | `heicode/model/agent_callback.go`、`heicode/controller/agent_callback.go`、`heicode/router/api-router.go` |
| 新增接口 | `POST /api/agent/callbacks/swarm-events` |
| 独立能力 | 本地模拟 payload 可保存、去重、查询;已补普通 sub 任务事件 schema 校验 |
| 测试 | controller 单测覆盖正常保存、重复 event_id 幂等、明文密钥拒绝、handoff 缺字段拒绝 |
验收标准:
- 请求体不得出现 token/password/private_key/access_key/connection_string 明文字段。
- 每个回调都有 `event_id` 或 `idempotency_key`。
- 重复回调返回成功但不重复写入。
- `task.*` / `handoff.*` 关键事件缺少必填字段时返回 `CALLBACK_SCHEMA_INVALID`。
### 任务 6:artifact 数据模型和 API
| 项 | 内容 |
|---|---|
| 目标 | Manager 保存和展示 artifact 摘要,不等 Runtime 真实输出 |
| 修改文件 | 新增 `heicode/model/agent_artifact.go`、`heicode/controller/agent_artifact.go`、`heicode/web/default/src/features/agent-console/api.ts`、`heicode/web/default/src/features/agent-console/pages.tsx` |
| 字段 | `artifact_id`、`deployment_id`、`task_id`、`correlation_id`、`artifact_type`、`title`、`summary`、`uri`、`checksum`、`metadata_json`、`created_at` |
| 测试 | model/controller 单测;前端 typecheck |
验收标准:
- artifact 只保存摘要和引用,不保存大文件正文。
- `uri` 支持 `artifact://`、`git://`、`azblob://`、`https://`,但页面只展示安全摘要。
- 能按 `deployment_id` 查询 artifact 列表。
### 任务 7:回调幂等和服务身份校验骨架
| 项 | 内容 |
|---|---|
| 目标 | 生产联调前先有幂等和认证形状 |
| 修改文件 | `heicode/controller/agent_callback.go`、新增 `heicode/middleware/agent_callback_auth.go` |
| 机制 | `X-Request-Id`、`X-Correlation-Id`、`Idempotency-Key`、可选 `X-Agent-Signature` |
| 当前阶段 | 可以先用配置开关和本地测试 token,不接真实 Key Vault service token |
| 测试 | 缺少服务 token 时拒绝;重复 key 不重复写入 |
验收标准:
- 开发环境可配置跳过严格签名,但生产默认要求服务身份。
- 日志不打印 token 或签名原文。
- 幂等冲突能返回已有记录摘要。
### 任务 8:持久化 SK snapshot
| 项 | 内容 |
|---|---|
| 目标 | 替换当前 `agentSnapshots` 内存 map |
| 修改文件 | 新增 `heicode/model/agent_sk_snapshot.go`,修改 `heicode/controller/agent_control_plane.go` |
| 当前问题 | 容器重启后 `/sk-snapshots` 丢失 |
| 测试 | 创建 snapshot 后清空内存,再从 DB 查询仍存在 |
验收标准:
- `deployment_id`、`snapshot_id` 有索引。
- 查询按创建时间倒序或稳定顺序返回。
- 不保存 SK 内容正文,只保存来源和版本引用。
### 任务 9:任务视角审计聚合
| 项 | 内容 |
|---|---|
| 目标 | 按 task/deployment/correlation_id 聚合 Manager 已有记录 |
| 修改文件 | `heicode/controller/agent_control_plane.go`、`heicode/model/agent_audit.go`、`heicode/web/default/src/features/agent-console/pages.tsx` |
| 聚合内容 | deployment、audit events、approvals、leases、artifacts、callbacks、resource grants |
| 新增接口 | `GET /api/agent/tasks/:task_id/timeline` 或 `GET /api/agent/deployments/:id/timeline` |
| 测试 | 同一 correlation_id 下能聚合多类事件 |
验收标准:
- 缺少某类数据时返回空数组,不报错。
- 时间线按时间排序。
- 敏感字段统一脱敏。
### 任务 10:本地模拟蜂群事件冒烟入口
| 项 | 内容 |
|---|---|
| 目标 | 在蜂群 Runtime 未接入前,Manager 能用模拟事件自测完整链路 |
| 修改文件 | `heicode/controller/agent_callback.go`、`heicode/router/api-router.go`、可选新增 `heicode/controller/agent_smoke.go` |
| 接口建议 | admin-only `POST /api/agent/dev/simulate-run` |
| 生成内容 | deployment accepted、task.created、task.claimed、task.heartbeat、task.blocked、handoff.requested、handoff.completed、task.retried、task.completed、artifact.created、approval.requested、timeline.updated |
| 测试 | 单测验证模拟后 timeline/artifacts/audit 可查;`scripts/agent_sub_mode_smoke.py` 可跑生产健康和 deployment timeline 冒烟 |
验收标准:
- 该接口必须 admin-only 或 dev-only。
- 响应明确 `simulated: true`。
- 线上页面不能把模拟事件显示成真实 Runtime 事件。
- task/blocked/retry/handoff 事件能在 timeline 中看到,来源标记为 `simulated`。
### 任务 11:前端页面补齐独立闭环展示
| 项 | 内容 |
|---|---|
| 目标 | 用户能看到 Manager 自己可提供的闭环信息 |
| 修改文件 | `heicode/web/default/src/features/agent-console/pages.tsx`、`api.ts`、必要时新增组件 |
| 展示内容 | sub_mode、deployment 来源 task、callbacks、artifacts、timeline、SK snapshots 持久化状态 |
| 测试 | `cd heicode/web/default && bun run typecheck`;本地页面点击冒烟 |
验收标准:
- 页面明确区分 `control-plane placeholder`、`simulated`、`runtime` 来源。
- 没有 artifact/callback 时有空态。
- 文案不宣称真实蜂群已完成。
### 任务 12:文档口径清理
| 项 | 内容 |
|---|---|
| 目标 | 清理旧 Vault/OpenBao/Secret Provider 误导表述 |
| 修改文件 | `docs/heicode.md`、`docs/plan.md`、`docs/heicode-manager-sub-swarm-progress-checklist.md` |
| 规则 | 用户侧叫“密钥保管器”,当前实现侧写 Azure Key Vault |
| 验收 | `rg -n "OpenBao|HashiCorp Vault|Secret Provider" docs` 后剩余内容必须是历史说明或明确非当前实现 |
验收标准:
- 不把 Azure Key Vault 写成普通用户要进入的后台。
- 不删除历史架构背景时,必须标注“历史/非当前实现”。
### 任务 13:AWS/GCP 占位提示
| 项 | 内容 |
|---|---|
| 目标 | 避免用户误以为 AWS/GCP 已完成 |
| 修改文件 | 资源绑定相关前端页面、`heicode/controller/resource.go` 如需补充状态字段 |
| 当前现实 | Azure 已有第一阶段发现,AWS/GCP 未实现 |
| 验收 | UI 明确显示 AWS/GCP “即将支持”或禁用状态 |
验收标准:
- 禁用项不能提交到后端创建真实资源发现。
- 已有 Azure 流程不受影响。
### 任务 14:请求 body 加密策略确认
| 项 | 内容 |
|---|---|
| 目标 | 桌面客户端调用 Manager 的 sub 流程接口时,支持与模型调用一致的 V2 应用层 body 加密;浏览器后台页面继续兼容普通 HTTPS JSON |
| 修改文件 | `docs/integration/heicode-desktop-sub-agile-api.md`、`docs/heicode-runtime-auth-newapi-secret-design.md`、必要时补充 `heicode/middleware` 或客户端 SDK 加解密模块 |
| 当前现实 | `/api/agent/user/*` 与 `/api/heicode-auth/*` 已支持 `Content-Encoding: heicode-aead-v1`;Manager 会先解密并校验设备签名,再把明文 JSON 交给业务处理或代理给上游 |
| 安全红线 | 请求体、回调、日志、artifact metadata、timeline 中禁止出现明文密码、Token、私钥、连接串、云 access key、模型 key;长期凭证只传 `secret_ref` |
| 后续判断 | 桌面客户端需要在 sub 流程调用中复用模型调用的 encryptedFetch/V2 签名能力;服务端保留未加密 JSON 只用于 Manager Web 控制台兼容 |
| 测试 | 单测覆盖 V2 malformed 请求拒绝、普通代理请求兼容;生产需用真实已配对设备发起加密 sub 请求冒烟 |
验收标准:
- 文档明确桌面端优先走 V2 应用层 body 加密,且保留浏览器后台未加密 JSON 兼容。
- 客户端对接文档明确禁止传明文长期凭据,只允许 `secret_ref` / `lease://...` / 资源元数据。
- 服务端日志不打印请求 body 中的敏感字段。
- V2 加密失败时返回 `X-Heicode-Auth-Error` 和 `X-Heicode-Server-Time`,便于客户端排障。
### 任务 15:Runtime 状态诊断与兜底产物识别
| 项 | 内容 |
|---|---|
| 目标 | Manager 不依赖客户端或 Runtime 改接口,也能把“callback 已到”和“Runtime/Agent 是否真的产出交付物”分开展示 |
| 修改文件 | `heicode/controller/agent_runtime_client.go`、`heicode/router/api-router.go`、`heicode/web/default/src/features/agent-console/api.ts`、`heicode/web/default/src/features/agent-console/pages.tsx` |
| 新增 API | `GET /api/agent/user/deployments/:deployment_id/runtime-diagnostics` |
| 诊断来源 | 用 deployment 记录里的 `runtime_swarm_id` / `runtime_deployment_id` 查询 Runtime status;默认路径 `/api/swarms/{swarm_id}/status`,可用 `AGENT_RUNTIME_STATUS_PATH` / `SWARM_RUNTIME_STATUS_PATH` 配置 |
| 普通 sub / 蜂群边界 | 返回 `runtime_mode`,页面分别显示“普通 sub 模式”或“蜂群模式”,不把二者合并成一个流程 |
| 已识别异常 | Runtime agent failed、completed 但存在 failed agents、只有 `Runtime execution summary` 兜底产物、模型 token 用量为 0、Runtime 状态查询失败 |
| 测试 | `go test ./controller -run TestAgentRuntimeDiagnosticsWarnsOnCompletedRuntimeWithFailedAgents -count=1`;前端 `bun run build:check` |
验收标准:
- 诊断接口只读,不改 deployment 状态,不伪造 artifact。
- 页面中的兜底摘要必须明确提示“不是最终业务交付物”。
- 普通 sub 与蜂群模式必须通过 `runtime_mode` 区分展示。
- Runtime 状态查不到时返回可读 warning,不能影响 Manager 已落库 callback/timeline/artifact 查询。
### 任务 16:Artifact 完整内容代理下载
| 项 | 内容 |
|---|---|
| 目标 | 对齐 Agent Manager v2.1.10 的产物获取流程:Manager / 前端先查 artifact 列表,再通过用户态 content 代理接口获取完整文件 |
| 修改文件 | `heicode/model/agent_artifact.go`、`heicode/controller/agent_callback.go`、`heicode/controller/agent_runtime_client.go`、`heicode/router/api-router.go`、`heicode/web/default/src/features/agent-console/pages.tsx` |
| 新增 API | `GET /api/agent/user/deployments/:deployment_id/artifacts/:artifact_id/content` |
| Runtime 目标路径 | 默认 `GET /api/swarms/{swarm_id}/artifacts/{artifact_id}/content`,可用 `AGENT_RUNTIME_ARTIFACT_CONTENT_PATH` / `SWARM_RUNTIME_ARTIFACT_CONTENT_PATH` 配置 |
| 安全边界 | Manager 先校验当前用户拥有 deployment,再校验 artifact 属于该 deployment;不暴露 Azure Blob 凭据、SAS URL 或 Runtime 内网信息 |
| 测试 | `go test ./controller -run TestAgentArtifactContentProxiesRuntimeContent -count=1` |
验收标准:
- 不能直接用用户传入的 URI 下载,必须以 Manager 已落库 artifact 为准。
- 响应透传 Runtime 的文件正文、`Content-Type` 和 `Content-Disposition`。
- Runtime 未配置或 content 拉取失败时返回明确业务错误。
- 页面 artifact 卡片提供“下载产物”,但兜底摘要仍需标记为非最终业务交付物。
## 五、推荐执行批次
### 批次 A:最小 Manager 闭环
| 顺序 | 任务 |
|---:|---|
| 1 | sub 模式字段 |
| 2 | 用户态 Agent deployment API |
| 3 | HeicodeTask 到 deployment 桥接 |
| 4 | 本地模拟蜂群事件冒烟入口 |
完成批次 A 后,Manager 应能做到:普通用户从任务卡发起一个本地 deployment,并通过模拟事件看到任务进展,不依赖真实 Runtime。
### 批次 B:生产联调准备
| 顺序 | 任务 |
|---:|---|
| 1 | `/api/swarms` 与 `/api/agent/deployments` adapter |
| 2 | callback 接收端骨架 |
| 3 | artifact 数据模型和 API |
| 4 | 回调幂等和服务身份校验骨架 |
完成批次 B 后,Manager 应能接收蜂群平台未来回调,并能用模拟 payload 证明幂等、落库、查询和脱敏正确。
### 批次 C:可观测与收尾
| 顺序 | 任务 |
|---:|---|
| 1 | SK snapshot 持久化 |
| 2 | 任务视角审计聚合 |
| 3 | 前端闭环展示 |
| 4 | 文档口径清理 |
| 5 | AWS/GCP 占位提示 |
| 6 | 请求 body 加密策略确认 |
| 7 | Runtime 状态诊断与兜底产物识别 |
| 8 | Artifact 完整内容代理下载 |
完成批次 C 后,Manager 端应具备清晰的任务视角、持久化上下文、准确页面口径和更少误导。
## 六、统一自测命令
每个批次完成后至少执行:
```bash
cd /Users/gongzhiyong/go/heicode-mananger/heicode
go test ./controller ./model
```
前端有改动时执行:
```bash
cd /Users/gongzhiyong/go/heicode-mananger/heicode/web/default
bun run typecheck
bun run build
```
文档有改动时执行:
```bash
cd /Users/gongzhiyong/go/heicode-mananger
git diff --check -- docs
```
如果涉及生产部署,必须再按:
```bash
cd /Users/gongzhiyong/go/heicode-mananger
sed -n '1,220p' docs/deployment/azure-production-deploy-guardrails.md
```
## 七、完成定义
Manager 独立任务完成,不等于蜂群生产闭环完成。本文完成的定义是:
| 条件 | 标准 |
|---|---|
| 普通用户路径 | 非管理员用户可以基于自己的任务和资源创建/查看/停止本地 deployment |
| 任务关联 | HeicodeTask 能关联 deployment 或 deployment draft |
| sub 模式 | `waterfall` / `agile` 在 DB、API、UI 可追踪 |
| 回调骨架 | 事件、artifact、approval-request、usage、status 可模拟回调落库 |
| artifact | artifact 摘要可保存、查询、展示 |
| 幂等 | 重复回调不重复写入 |
| 审计 | task/deployment/correlation_id 下能聚合审计、审批、artifact、callback |
| 安全 | API、日志、页面不出现明文长期密钥 |
| 请求加密 | 桌面端 sub 请求支持与模型调用一致的 V2 body 加密;浏览器后台普通 JSON 兼容路径不受影响 |
| Runtime 诊断 | Manager 页面能显示 Runtime 真实状态来源、失败 Agent、兜底产物和普通 sub / 蜂群模式归属 |
| Artifact 内容 | Manager 用户态接口能代理下载 Runtime 保存的完整 artifact 内容,且不暴露存储凭据 |
| 口径 | 页面和文档不把本地占位/模拟事件说成真实 Runtime |
## 八、执行时不能突破的边界
1. 不在 Manager 中保存长期明文密钥。
2. 不让普通用户看到 CodeGW 管理后台能力。
3. 不把 Manager 做成网页编码主体验。
4. 不把 `sub_mode` 解释成蜂群 Runtime 固定流程。
5. 不把本地模拟事件当真实蜂群完成证据。
6. 不把 Azure Key Vault 暴露成普通用户要直接操作的后台。
7. 不绕过客户端高危审批的产品边界;Manager 只能先提供记录和 API。
8. 不破坏 V2 body 加密的版本字段、诊断头、重放保护和未加密 Web 控制台兼容路径,避免客户端和 Manager 联调不可排障。
@@ -1,144 +0,0 @@
# Heicode Manager 普通 sub 与蜂群模式进度清单
更新时间:2026-05-30
负责人范围:Heicode Manager 端
用途:给负责人、上级和联调同学快速确认 Manager 端在普通 sub 与蜂群模式下已经具备什么、还要做什么、哪些需要客户端或 Agent Manager / 蜂群项目配合。
## 资料来源
| 来源 | 用途 |
|---|---|
| `http://gitee.ath.cx:3000/taijibaga/fengqun/src/branch/main/docs` | 蜂群设计资料包,定义目标驱动蜂群、任务图、claim、heartbeat、handoff、artifact、审批、审计和三方分工 |
| `http://gitee.ath.cx:3000/taijibaga/HeiCode-Swarm` | 蜂群项目实现资料,当前 Orchestrator/Agent/Redis/K8s/桌面演示客户端的实际结构 |
| `docs/product-package/07-integration-boundaries.md` | Heicode、Manager、Agent 平台、CodeGW、Azure Key Vault 的边界 |
| `docs/integration/heicode-desktop-sub-agile-api.md` | Heicode 桌面客户端接 Manager 的普通 sub 敏捷流程 |
| `docs/integration/Heicode-Manager-PayPal支付接入与计费关系说明.md` | PayPal 收款、Heicode 余额/订阅、NewAPI 模型扣费和 Agent 运行费用边界 |
| 当前仓库 `heicode/` 代码 | Manager 端实际实现核查 |
## 一、核心边界
蜂群模式不是普通 sub 敏捷/瀑布本身。普通 sub 是 Heicode 的任务组织方式;蜂群是 Agent/Swarm Runtime 的执行方式。
| 模式 | Manager 当前职责 | Runtime / Agent Manager 当前职责 | 不能混淆的点 |
|---|---|---|---|
| 普通 sub 敏捷/瀑布 | 从任务卡生成 deployment、保存 `sub_mode`、权限清单、预算、callback、timeline、artifact、审批记录和运行时诊断 | 执行普通 sub 任务,回传阶段、日志、工具调用、artifact、usage、失败原因 | 普通 sub 不等于蜂群 task graph;`/api/swarms` 可作为 Runtime 兼容入口,但页面和文档必须按普通 sub 展示 |
| 蜂群模式 | 生成 swarm adapter 请求、保存 `deployment_id <-> swarm_id` 映射、接回调、审批、审计、artifact 展示和运行时诊断 | 创建 Swarm Run、任务图、claim、heartbeat、handoff、Agent 编队、真实执行与结果回传 | 蜂群是执行形态;不能把普通 sub 的阶段状态误写成蜂群已完成 |
| 系统 | 定位 | 应该做什么 | 不应该做什么 |
|---|---|---|---|
| Heicode 桌面客户端 | 用户主体验 | 输入目标、持续补充需求、查看反馈、审批高危操作、接收交付结果 | 直接配置 AKS、模型供应商、完整蜂群 payload |
| Heicode Manager | 控制面、记录面和用户侧账本入口 | 资源绑定、`secret_ref`、权限清单、生成启动请求、记录 deployment/swarm 映射、回调、artifact、timeline、审批、审计、余额/订阅展示 | 替代客户端做主开发对话,替代 Runtime 执行任务,或把 PayPal 收款当成模型扣费链路 |
| HeiCode-Swarm / Agent Runtime | 执行层 | 创建 Swarm Run、任务图、Agent 编队、claim、heartbeat、handoff、执行、结果回传、真实运行 usage 回传 | 保存长期明文密钥,直接暴露给普通用户,或自行决定用户账本扣费 |
## 二、目标调用链
```text
Heicode 桌面客户端
-> Heicode Manager
- V2 加密请求 body
- task/deployment draft
- resource_grants / secret_ref / budget / approval_policy
-> Agent Runtime 或 HeiCode-Swarm
- POST /api/swarms 或兼容创建入口
- 返回 swarm_id / runtime_deployment_id
<- Runtime callback
- swarm-events / artifact.created / approval.requested / timeline.updated
<- Manager 查询接口
- deployment detail / events / logs / metrics / artifacts / sk-snapshots / timeline
<- 桌面客户端展示和审批
```
## 三、当前 Manager 已完成项
以下只按当前仓库代码确认,不把规划项写成已完成。
| 能力 | 当前状态 | 代码证据 |
|---|---|---|
| sub 模式字段 | 已支持 `sub_mode`,默认 `agile`,校验 `agile/waterfall` | `heicode/controller/agent_control_plane.go`、`heicode/model/agent_deployment.go` |
| 用户态 deployment | 已有 `/api/agent/user/deployments` 创建、查询、停止、日志、事件、指标、artifact、SK snapshot、timeline | `heicode/router/api-router.go` |
| 任务到 deployment draft | 已有 `/api/agent/user/tasks/:task_id/deployment-draft` | `heicode/controller/agent_task_bridge.go` |
| `/api/swarms` 兼容入口 | 已有用户态 `POST /api/swarms`,内部走 Manager deployment 创建,并作为 adapter source 记录 | `heicode/router/api-router.go`、`AgentCreateUserSwarm` |
| Runtime 创建桥接 | 已能按配置调用 Runtime 创建接口,默认路径 `/api/agent/deployments`,可用环境变量改为蜂群创建路径 | `heicode/controller/agent_runtime_client.go` |
| Runtime stop 桥接 | 已能在停止 Manager deployment 时调用 Runtime stop | `heicode/controller/agent_runtime_client.go` |
| Runtime 状态诊断 | 已新增用户态只读诊断接口,按 `runtime_mode` 区分普通 sub / 蜂群,查询 Runtime status 并识别 failed agent、兜底摘要 artifact、零 token 用量等异常 | `heicode/controller/agent_runtime_client.go`、`AgentGetUserDeploymentRuntimeDiagnostics` |
| callback 接收 | 已有 `POST /api/agent/callbacks/swarm-events` | `heicode/controller/agent_callback.go` |
| callback 鉴权 | 支持 `X-Agent-Service-Token` 和 HMAC 签名校验,并可从 Key Vault ref 读取签名密钥 | `heicode/controller/agent_callback.go` |
| callback 幂等 | `event_id` / `idempotency_key` 去重,重复回调返回成功但不重复写 | `heicode/model/agent_callback.go` |
| artifact 落库 | `artifact.created` 可生成 artifact 记录,支持用户态列表查询 | `heicode/model/agent_artifact.go`、`AgentListUserDeploymentArtifacts` |
| artifact 完整内容代理 | 已新增用户态 content 下载接口,Manager 校验 deployment/artifact 权限后代理 Runtime content 接口读取完整产物 | `AgentGetUserDeploymentArtifactContent`、`callAgentRuntimeArtifactContent` |
| approval 回调 | `approval.requested` 可转成 Manager 审批记录 | `heicode/controller/agent_callback.go` |
| 审批结果回传 Runtime | 用户 approve/reject 后,Manager 可按配置 POST 回 Runtime approval decision,且不发送 `secret_ref` | `heicode/controller/agent_approval.go`、`heicode/controller/agent_runtime_client.go` |
| timeline 聚合 | 用户态 timeline 聚合 audit、callbacks、artifacts、sk_snapshots | `AgentGetUserDeploymentTimeline` |
| SK snapshot 持久化 | 已有 `agent_sk_snapshots` 模型和列表查询 | `heicode/model/agent_sk_snapshot.go` |
| 本地模拟事件 | 已有用户态 `simulate-events`;默认模拟会写入 callback、artifact、approval、timeline 记录,用于 Manager 自测展示链路和脱敏检查 | `AgentSimulateUserDeploymentEvents` |
| V2 body 加密 | `/api/agent/user/*`、`/api/heicode-auth/*`、`/api/swarms` 已按同一套 V2 设备签名和 body 加密路径设计;未加密 Web 控制台仍兼容 session + `New-Api-User` | `heicode/middleware/auth.go`、`heicode/router/api-router.go` |
| 生产普通 sub 烟测记录 | 2026-05-31 已用生产 Manager 入口完成真实普通 sub 复核:`dep_1d6d66896cc6` -> `swm_03995f7c7a27`,`gpt-5.4`,`tokens_used=2682`,`newapi_request_id=chatcmpl-DlbZce3VZnsv5DptBILFoiRYy5ZHN`,业务 `code_patch` artifact 可通过 Manager content 接口下载 | `docs/integration/heicode-desktop-sub-agile-api.md` |
| PayPal/计费边界文档 | 已明确 PayPal 只是收款渠道;模型调用仍走 Heicode/NewAPI 的钱包或订阅额度;Agent 运行费用目前只有预算字段,真实收费需 Runtime usage 回传 | `docs/integration/Heicode-Manager-PayPal支付接入与计费关系说明.md` |
## 四、Manager 端还需要继续做的蜂群任务
| 优先级 | 任务 | 当前缺口 | 是否 Manager 可独立做 | 验收标准 |
|---|---|---|---|---|
| P0 | 把 `/api/swarms` adapter 文档化并固定字段 | 已完成:`docs/integration/蜂群模式-AgentManager对接任务清单.md` 和 `docs/integration/普通sub敏捷模式-AgentManager对接任务清单.md` 已写清 Runtime create、callback、source、`deployment_id <-> swarm_id` 映射 | 是 | 文档可直接发给 Agent Manager / 蜂群侧核对 |
| P0 | 增加 Swarm Run 显示字段 | 已完成:用户态 deployment 详情展示 `runtime_deployment_id`、`runtime_swarm_id`、`runtime_state`,并在相关记录里展示 source | 是 | 用户态 deployment 详情和后台页面能看到 Runtime 映射 |
| P0 | 回调事件类型收敛 | 已完成:`GET /api/agent/callbacks/swarm-events/schema` 输出事件类型、分类和必填字段;callback 接收端按 schema 校验关键 task/handoff/artifact/approval 字段 | 是 | `task.created/claimed/running/completed/failed/handoff/approval/artifact` 都有 schema |
| P0 | Runtime 联调配置模板 | 已完成:两份 Agent Manager 对接任务清单已写清 `AGENT_RUNTIME_*`、callback URL、service token/HMAC 方式和验收步骤 | 是 | 蜂群项目按模板能调用 Manager callback |
| P1 | 审批结果回传 Runtime 联调 | Manager adapter 已有;仍需要 Runtime 提供接收接口并验证状态继续/停止 | 需要 Runtime 接口 | 审批通过/拒绝后 Runtime 状态能继续或停止 |
| P1 | Artifact 展示优化 | Manager 端已完成:页面展示 artifact 类型、摘要和 URI;真实 `code_patch/document/test_report/deployment_manifest` 仍需 Runtime 输出 | 需要 Runtime 数据 | artifact 页面/详情能按类型展示摘要和链接 |
| P1 | Artifact 完整内容下载 | 已完成:用户态 `/artifacts/{artifact_id}/content` 代理 Runtime content,页面提供下载入口 | 是 | artifact 属于当前用户 deployment 才能下载,响应透传 Runtime 文件内容 |
| P1 | 任务图/Agent 状态展示占位 | 已完成:页面从 `task.*` / `handoff.*` callback 聚合 Agent task map;无真实数据时显示 Runtime callback 空态 | Manager 可先做展示结构,真实数据需 Runtime | 有空态和字段,不宣称真实已运行 |
| P1 | Runtime 状态诊断展示 | 已完成:任务总览详情页显示运行模式、运行状态、数据来源和异常 warning;兜底摘要产物会提示“不是最终业务交付物” | 是 | 页面能识别 callback 已到但 Runtime agent 失败、只返回兜底摘要的情况 |
| P1 | 日志/指标真实来源标识 | 已完成:logs/metrics API 返回 `data_source`、`runtime_source`,当前明确是 Manager control-plane / estimated,不伪装 Runtime 真实指标 | 需要 Runtime 数据 | 页面和 API 响应能区分来源 |
| P1 | Agent 运行费用口径收敛 | 已完成文档口径:`budget.max_tokens/max_cost_usd/max_duration_sec` 是预算约束,不等于真实扣费账本;真实收费必须依赖 Runtime 回传 usage | Manager 已完成文档,真实数据需 Runtime | 页面/文档不把 estimated budget 说成真实扣费 |
| P1 | 高危审批客户端联动文档 | 已完成:`docs/integration/heicode-desktop-sub-agile-api.md` 已包含 approval 查询、approve/reject、awaiting_approval 流程 | 是 | 客户端文档补齐 approval flow |
| P2 | 蜂群模式验收脚本 | 已完成:`scripts/agent_sub_mode_smoke.py` 支持 schema 检查、生产健康检查、可选 simulate-events、可选真实 callback smoke | 是 | 本地/生产能跑出 callback、artifact、approval、timeline 可见 |
| P2 | 生产 schema / 认证链路复测 | 本地代码和测试已覆盖;生产公开 `GET /api/agent/callbacks/swarm-events/schema` 当前返回 404,认证接口需有效登录态或后台 token 才能测 | 是,部署后复测 | 生产 schema 返回 200,用户态/后台态 smoke 能拿到真实数据 |
## 五、需要蜂群项目配合的事项
| 事项 | 为什么 Manager 不能单独完成 | 蜂群侧需要提供 |
|---|---|---|
| 真实 Swarm Run | Manager 只能发起请求和记录,不能替 Runtime 创建任务图 | 生产 `POST /api/swarms` 或确认使用现有 `/tasks` 兼容方式 |
| 真实 task graph | 任务拆解、依赖、状态机在 Runtime 内部产生 | `swarm_tasks`、依赖关系、状态枚举 |
| claim / heartbeat / release | 这是 worker runtime 行为 | 事件回调或查询接口 |
| handoff / retry / blocked | 任务交接和失败恢复属于 Runtime | 标准事件、重试次数、失败原因、下一步动作 |
| Agent 执行结果 | Manager 不能生成真实代码产物 | artifact schema、Git branch/commit、测试报告、部署结果 |
| Runtime 指标 | CPU、内存、耗时、Agent 存活、任务耗时来自集群 | metrics 查询或 Prometheus 指标映射 |
| Runtime 真实用量和成本 | Manager 只能保存预算和回传结果,不能凭本地估算扣真实 Agent 运行费用 | `model_tokens`、`model_cost_usd`、`runtime_seconds`、`cpu_core_seconds`、`memory_mb_seconds` 等 usage callback |
| 审批等待状态机 | Runtime 要能暂停高危动作并等待 Manager/客户端审批 | approval request 和 approval decision API |
## 六、需要桌面客户端配合的事项
| 事项 | Manager 已有基础 | 客户端需要做 |
|---|---|---|
| V2 加密请求 | Manager 已支持 | sub/蜂群相关 POST 请求复用模型调用加密 |
| 任务创建和追问 | Manager 有 `/api/heicode-auth/*` 代理 | 带 Heicode access token 调用任务接口 |
| deployment draft | Manager 有用户态接口 | 从任务卡调用 draft,再创建 deployment/swarm |
| 进度展示 | Manager 有 detail/events/timeline/artifacts 接口 | 做用户主体验展示,不暴露底层 payload |
| 高危审批 | Manager 有 approval API 和回调转审批记录 | 弹窗展示风险、资源、TTL,并提交 approve/reject |
## 七、当前不应误报为完成的项
| 项 | 当前真实状态 |
|---|---|
| 蜂群生产闭环 | 未完成。Manager 有控制面和回调骨架,但真实 Runtime 任务图/Agent 执行仍需蜂群项目联调 |
| HeiCode-Swarm 项目等于正式 Heicode 桌面客户端 | 不是。它有自己的 `desktop-client` 演示端,正式链路应走 Heicode 桌面客户端 -> Manager -> Runtime |
| `/api/swarms` 已等于真实 Runtime Swarm Run | 不是。Manager 侧已有 adapter 入口,但是否真实创建 Swarm Run 取决于 Runtime 配置和蜂群接口 |
| artifact/timeline 有接口就等于有真实产物 | 不是。Manager 能接和展示,真实产物必须由 Runtime 回调 |
| `Runtime execution summary` 就等于最终交付物 | 不是。Manager 页面会标记这是兜底摘要;真实最终交付物必须是 Runtime/Agent 返回的 `code_patch`、`document`、`test_report`、Git branch/commit、部署地址等可核对 artifact |
| 高危审批在 Manager 里点完就闭环 | 不是。产品要求桌面客户端主审批,并且 Runtime 要收到 decision |
| 本地测试通过就等于生产接口全通 | 不是。本地 router/controller/middleware 测试能证明代码能力;生产仍必须确认对应镜像、路由和认证配置已生效 |
| Agent budget 就等于真实收费 | 不是。当前 `budget` 是执行上限和审计字段;真实收费需要 Runtime/Agent Manager 回传可核对 usage |
| PayPal 接入会改变模型扣费方式 | 不是。PayPal 只是充值/购买订阅的收款渠道,模型调用仍从钱包余额或内部订阅额度扣 |
## 八、后续执行顺序
| 顺序 | 任务 | 负责人范围 | 备注 |
|---:|---|---|---|
| 1 | 固定 Manager -> Swarm adapter 契约 | Manager | 先把 `/api/swarms`、Runtime create path、callback 字段写死成可联调文档 |
| 2 | 跑一次本地模拟 Runtime callback | Manager | 已有默认模拟链路;继续用于验证 callback/artifact/timeline/approval 去重和脱敏 |
| 3 | 给蜂群项目配置 callback URL 和 service token | Manager + 蜂群 | 不传明文长期密钥 |
| 4 | 用 HeiCode-Swarm 当前 Orchestrator 做兼容测试 | Manager + 蜂群 | 先判断是否走 `/tasks` 适配,还是蜂群侧补 `/api/swarms` |
| 5 | 桌面客户端按文档跑任务 -> draft -> create -> timeline -> approval | 客户端 + Manager | 使用 V2 加密 POST |
| 6 | 核对 Runtime usage 回传字段 | Manager + 蜂群 | 至少覆盖模型 token/cost、运行时长、Agent role、deployment/task/correlation |
| 7 | 补页面来源标识和任务图空态 | Manager | 防止把 simulated/control-plane 误认为 runtime |
-112
View File
@@ -1,112 +0,0 @@
# Heicode Manager 蜂群模式缺失对照文档
更新时间:2026-05-27
对照范围:`fengqun` 设计资料、`HeiCode-Swarm` 项目现状、Heicode 产品资料包、当前 Heicode Manager 代码。
## 一、对照结论
Manager 端已经具备蜂群联调需要的控制面基础:用户态 deployment、task draft、`/api/swarms` adapter、Runtime 创建/停止桥接、callback 接收、artifact、approval、approval decision 回传 adapter、timeline、SK snapshot、V2 加密请求。本地 `simulate-events` 默认链路也能写入 task、blocked、retry、handoff、artifact、approval、timeline 记录,便于 Manager 自测页面展示和脱敏。
仍然缺的是“真实蜂群 Runtime 产生的数据和状态机”。也就是说,Manager 不是完全没写;缺口主要集中在 Runtime 真正创建 Swarm Run、任务图、Agent 执行、handoff、产物和审批继续/停止闭环。
## 二、按文档要求逐项对照
| 文档要求 | 当前 Manager 状态 | 是否满足 | 缺失原因 | 需要谁 |
|---|---|---|---|---|
| Heicode 客户端是主体验 | Manager 文档和接口已按客户端调用设计,V2 加密 POST 已支持 | 部分满足 | 桌面客户端还需按文档接任务、展示进度和审批 | 客户端 |
| Manager 是受控入口,不暴露完整 payload 给普通用户 | 用户态 draft/create 已有,可由任务卡生成 plan | 部分满足 | 页面还需要更弱化底层 payload,突出启动摘要和状态 | Manager |
| Manager 调用 `POST /api/swarms` 创建 Swarm Run | Manager 已有 `/api/swarms` adapter,Runtime create path 可配置 | 部分满足 | 是否真实创建 Swarm Run 取决于蜂群 Runtime 是否提供生产接口 | 蜂群 + Manager |
| 保存 `swarm_id`、状态、请求摘要、`correlation_id` | 模型已有 `runtime_swarm_id`、`runtime_state`、payload JSON、correlation metadata | 基本满足 | 真实 `swarm_id` 需要 Runtime 返回 | 蜂群 |
| 接收 `swarm-events` 回调 | 已有 `POST /api/agent/callbacks/swarm-events` | 满足接收能力 | 还缺真实 Runtime 持续回调 | 蜂群 |
| 重复回调幂等 | 已按 `event_id` / `idempotency_key` 去重 | 满足 | 需要蜂群侧稳定传唯一事件 ID | 蜂群 |
| 接收 artifact 回调 | `artifact.created` 可落库并查询 | 满足接收能力 | 真实 artifact schema 和文件/分支引用需 Runtime 输出 | 蜂群 |
| 展示 Swarm 状态、事件、产物 | deployment detail/events/artifacts/timeline 已有,页面已强化 runtime/simulated 来源和 artifact 类型/URI | 基本满足 | 真实展示内容仍依赖 Runtime 回调真实数据 | 蜂群 |
| 展示 task graph、claim、heartbeat | Manager 已可接收并展示普通 task flow callback;没有独立 task graph 状态表 | 部分满足 | 真实任务图仍需要 Runtime 产出 task/agent 事件 | 蜂群 |
| 展示 handoff、blocked、retry | 已补 `task.*` / `handoff.*` schema 校验和页面任务流展示 | 基本满足接收和展示 | 真实数据仍需要 Runtime 持续回调 | 蜂群 |
| 高危审批请求进入 Manager | `approval.requested` callback 可转审批记录;用户 approve/reject 后可按配置 POST 回 Runtime | 部分满足 | 还需要蜂群 Runtime 提供并验证 approval decision 接收接口 | Manager + 蜂群 |
| 高危审批在客户端主体验完成 | Manager 有审批 API | 部分满足 | 桌面客户端要弹窗、轮询/订阅、提交决定 | 客户端 |
| 短期凭证和长期密钥隔离 | Manager 使用 `secret_ref` / `lease://` 记录,不传明文 | 基本满足 | Runtime 侧短期凭证派生/注入未验证 | 蜂群 + 基础设施 |
| CodeGW 用量归属 | Manager 有模型/余额基础,Runtime payload 带 billing_context | 部分满足 | 子 Agent 调用用量按 task/deployment/role 回流未验证 | 蜂群 + CodeGW + Manager |
| 日志和指标 | Manager 有 logs/metrics 接口占位和 runtime state | 部分满足 | CPU/内存/Agent 存活/任务耗时等真实指标来自 Runtime/AKS | 蜂群 + 基础设施 |
| 最终交付回流 | artifact/timeline 接收能力已有 | 部分满足 | 最终交付结果、Git branch/commit、部署 URL 需要 Runtime 输出,客户端展示 | 蜂群 + 客户端 |
## 三、HeiCode-Swarm 项目现状对 Manager 的影响
从 `HeiCode-Swarm` 项目 README 和代码看,当前项目结构是:
```text
desktop-client -> Orchestrator(FastAPI) -> Redis -> Agent Pods
```
当前 Orchestrator 主要入口:
| 入口 | 当前作用 | 与 Manager 目标契约差异 |
|---|---|---|
| `GET /health` | 健康检查 | 可直接用于 Runtime health |
| `POST /tasks` | 创建任务 | 不是文档要求的 `POST /api/swarms`,字段也不是 Heicode task/resource/secret/budget 结构 |
| `GET /tasks` / `GET /tasks/{id}` | 查询任务 | 可作为早期状态查询,但缺 `swarm_id` 维度 |
| `GET /agents` | Agent 列表 | 可映射到 Agent 状态 |
| `GET /handoffs` | handoff 历史 | 可映射到 Manager timeline |
| `GET /metrics` | Prometheus 指标 | 可映射到 Manager metrics |
| `WS /ws/{agent_id}` | Agent 注册、心跳、任务、结果 | Manager 不应直接接 Agent WS,应该由 Runtime 汇总后回调 Manager |
因此,Manager 后续联调有两种路线:
| 路线 | 说明 | 风险 |
|---|---|---|
| 蜂群侧补正式 `POST /api/swarms` | 最符合设计文档,Manager adapter 直接对接 | 需要蜂群项目改接口 |
| Manager 临时适配 `POST /tasks` | 可以先跑通现有 Orchestrator | 字段语义不足,无法完整覆盖 resource_grants、secret_ref、approval_policy、budget、artifact callback |
建议:生产目标仍以 `POST /api/swarms` 为准;短期可以做 `/tasks` 兼容测试,但必须标记为兼容桥接,不作为最终契约。
## 四、缺失项清单
### P0:影响蜂群主流程
| 缺失项 | 当前状态 | 处理建议 |
|---|---|---|
| 真实 `POST /api/swarms` 联调 | Manager 有 adapter,蜂群当前可见接口是 `/tasks` | 蜂群侧确认是否补 `/api/swarms`;Manager 固定 adapter 文档 |
| `deployment_id <-> swarm_id` 真实映射 | Manager 字段已准备,真实值需 Runtime 返回 | Runtime create response 必须返回 `swarm_id` |
| task graph / claim / heartbeat 事件 | Manager 可接收/展示 task flow callback;缺真实 Runtime 数据 | 蜂群侧定义并回调 `task.created/claimed/heartbeat/released/completed/failed` |
| artifact 真实产出 | Manager 能落库,缺 Runtime 产出 | 蜂群侧回调 `artifact.created`,带 Git branch/commit 或存储 URI |
| 审批结果回传 Runtime | Manager adapter 已完成,真实闭环未验证 | 蜂群侧提供接收接口并验证 approved/rejected 后继续或停止 |
### P1:影响可观测和验收
| 缺失项 | 当前状态 | 处理建议 |
|---|---|---|
| handoff/retry/blocked 展示 | Manager 已有 schema 校验、模拟事件和页面任务流展示 | 用真实 Runtime callback 做生产联调验证 |
| Runtime 日志/指标 | Manager 有 logs/metrics 位置,缺真实数据源 | 蜂群侧提供日志摘要或查询接口;指标对齐 Prometheus |
| 事件来源标识 | API 有部分 runtime/simulated 状态,页面还需强化 | Manager 页面区分 `manager`、`runtime`、`simulated` |
| 桌面客户端审批主流程 | Manager 有 API,客户端未完成主体验 | 客户端按 Manager approval API 接入 |
| 子 Agent 用量归属 | billing_context 有,真实用量未回流 | Runtime 调模型时带 task/deployment/role correlation |
### P2:完善项
| 缺失项 | 当前状态 | 处理建议 |
|---|---|---|
| 蜂群验收脚本 | 已新增 `scripts/agent_sub_mode_smoke.py`,可检查生产 Manager、Agent health 和指定 deployment timeline | 后续按真实 Runtime deployment 固化执行参数 |
| 页面术语统一 | sub/蜂群容易混淆 | 页面和文档统一:sub 是任务组织,swarm 是执行层 |
| 兼容 `HeiCode-Swarm` demo client 的说明 | 容易误认为正式 Heicode 桌面客户端 | 文档明确 demo client 不等于 `cc-haha` 正式客户端 |
## 五、验收口径
蜂群模式不能只看 Manager 页面有没有数据。必须同时满足:
1. 桌面客户端能通过 Manager 发起任务,POST 请求体走 V2 加密。
2. Manager 创建或桥接 Swarm Run,并保存 `deployment_id`、`runtime_deployment_id`、`swarm_id`。
3. Runtime 真实生成任务图,并回传任务状态、Agent 状态、handoff、artifact、审批请求。
4. Manager 对 callback 去重、落库、脱敏,并能按 deployment/timeline/artifact 查询。
5. 高危审批由客户端展示并提交,Manager 记录,Runtime 收到决定后继续或停止。
6. 交付物能回到 Manager 和客户端,不能只停留在 Runtime Redis 或 Agent 日志里。
7. 全链路日志和页面不得出现长期明文密钥、模型 key、云 access key、私钥或连接串。
## 六、下一步建议
| 顺序 | 动作 | 目标 |
|---:|---|---|
| 1 | 把 `docs/heicode-manager-sub-swarm-progress-checklist.md` 发给蜂群侧确认 | 让对方知道 Manager 已有什么、需要他们回什么 |
| 2 | 确认蜂群侧最终入口是 `/api/swarms` 还是先兼容 `/tasks` | 避免双方接口错位 |
| 3 | 用 Manager callback 接口跑一次蜂群侧真实 `artifact.created` / `approval.requested` | 证明回调、artifact、approval、timeline 有真实数据 |
| 4 | 客户端按 `docs/integration/heicode-desktop-sub-agile-api.md` 接入审批和 timeline | 跑通用户主体验 |
| 5 | 根据真实联调结果更新本文状态 | 把缺失项从“缺接口/未联调”改为“已验证/阻塞/延期” |
@@ -1,677 +0,0 @@
# Agent Manager 蜂群 Runtime 接口实现要求
更新时间:2026-05-28
面向对象:Agent Manager / HeiCode-Swarm Runtime 开发负责人
用途:Agent Manager 按本文实现接口、字段和回调后,Heicode Manager 可直接联调蜂群模式。
## 1. 结论
Heicode Manager 已负责控制面和记录面:
- 生成 deployment / swarm 创建请求。
- 传递 `resource_grants`、`secret_ref`、预算、模型网关上下文和 callback 地址。
- 保存 `deployment_id <-> runtime_deployment_id <-> swarm_id` 映射。
- 接收 Runtime callback,落库 artifact / approval / timeline / audit。
- 用户 approve/reject 后,把审批决定回传 Runtime。
Agent Manager / Swarm Runtime 需要负责执行层:
- 创建真实 Swarm Run。
- 生成真实 task graph。
- 管理 Agent claim / heartbeat / handoff / retry / blocked / failed 状态机。
- 真实执行任务并产出 artifact。
- 在高危动作前暂停并回调审批请求。
- 接收审批结果后继续或停止。
- 回传日志、指标、用量和最终结果。
### 1.1 2026-05-28 生产联调实测结论
使用测试用户 `zsbgnw@gmail.com`(Manager 用户 `chenchen`,`user_id=22`)在生产 Manager 上实测蜂群入口。
当前生产 Manager Runtime 配置实际为:
```text
AGENT_RUNTIME_BASE_URL=http://20.212.121.126
AGENT_RUNTIME_CREATE_PATH=/api/agent/deployments
AGENT_RUNTIME_STOP_PATH=/api/agent/deployments/{deployment_id}/stop
```
实测结果:
| 项 | 结果 |
|---|---|
| `GET /api/agent/runtime/health` | 通过,远端 Agent Manager IP healthy |
| `POST /api/swarms` 使用 `agent_runtime.platform=agent-swarm` | 失败,Manager 返回 `agent_runtime.platform must be agent when runtime context is present` |
| `POST /api/swarms` 使用 `agent_runtime.platform=agent` | 通过 |
| Manager deployment | `dep_72cdc138b9c8` |
| Manager 返回 swarm_id | `dep_72cdc138b9c8`,当前是 Manager 本地 deployment id |
| Runtime deployment | `dep_77370f27186f` |
| Manager events | `deployment.accepted`、`runtime.sync.started`、`runtime.sync.accepted` |
| Manager stop | 通过 |
| Runtime stop | 通过,Agent Manager 侧 `dep_77370f27186f` 状态为 `stopped` |
| Runtime callback | 未观察到,生产 Manager callback 表 0 条对应记录 |
| artifact / timeline callback | 未产出 |
| SK / usage callback | 未产出 |
| Runtime logs | 仅 `Pod ... has no logs yet` |
| Runtime metrics | 返回固定 CPU/内存/网络汇总,看起来仍是占位值 |
结论:
1. Heicode Manager 的 `/api/swarms` adapter 能接收用户态蜂群请求,并能通过当前 Runtime bridge 调 Agent Manager IP。
2. 当前生产链路实际走的是 Agent Manager `/api/agent/deployments`,不是 Agent Manager `/api/swarms`。
3. Manager stop 可以传递到 Agent Manager deployment stop。
4. 当前还没有真实蜂群 Runtime callback、task graph、artifact、SK、usage。
5. Agent Manager 如果要满足正式蜂群模式,仍需实现本文后续定义的 `/api/swarms`、`/api/swarms/{swarm_id}`、`/api/swarms/{swarm_id}/stop`、callback 和 usage 约束。
## 2. 总体调用链
```text
Heicode Desktop Client
-> Heicode Manager
POST /api/swarms 或 /api/agent/user/deployments
-> Agent Manager / Swarm Runtime
POST /api/swarms
<- Runtime response
runtime_deployment_id / swarm_id / status
<- Runtime callback
POST /api/agent/callbacks/swarm-events
-> Runtime approval decision
POST /api/swarms/{swarm_id}/approvals/{approval_id}
```
客户端到 Heicode Manager 的 `POST /api/swarms` 与普通 sub 用户态接口一样,必须支持 V2 body 加密:
```http
Content-Encoding: heicode-aead-v1
X-Heicode-Device-Id: <device_id>
X-Heicode-Timestamp: <unix_ms>
X-Heicode-Nonce: <nonce_hex>
X-Heicode-Fingerprint: <device_fingerprint>
X-Heicode-Eph-Pubkey: <base64_x25519_ephemeral_pubkey>
X-Heicode-Signature: <base64_ed25519_signature>
Content-Type: application/json
```
未加密 Web 控制台请求仍按 Manager session cookie + `New-Api-User` 兼容处理。Manager 调 Agent Manager / Swarm Runtime 的服务间请求不使用该客户端 body 加密协议,而使用 `Authorization: Bearer <service_token>` 和公共追踪 Header。
## 3. Agent Manager 必须提供的接口
| 优先级 | 方法 | 路径 | 必须 | 用途 |
|---|---|---|---:|---|
| P0 | `GET` | `/api/agent/health` | 是 | 健康检查 |
| P0 | `POST` | `/api/swarms` | 是 | 创建真实 Swarm Run |
| P0 | `POST` | `/api/swarms/{swarm_id}/stop` | 是 | 停止 Swarm Run |
| P0 | `POST` | `/api/swarms/{swarm_id}/approvals/{approval_id}` | 是 | 接收 Manager 审批决定 |
| P0 | callback | Manager `/api/agent/callbacks/swarm-events` | 是 | 回写状态、task、handoff、artifact、approval |
| P1 | `GET` | `/api/swarms/{swarm_id}` | 建议 | 查询 Runtime 详情 |
| P1 | `GET` | `/api/swarms/{swarm_id}/tasks` | 建议 | 查询 task graph |
| P1 | `GET` | `/api/swarms/{swarm_id}/logs` | 建议 | 查询日志 |
| P1 | `GET` | `/api/swarms/{swarm_id}/metrics` | 建议 | 查询指标 |
如果短期无法提供 `/api/swarms`,可以临时确认兼容路径,例如 `/tasks`。但这只能作为联调过渡,不作为最终生产契约。
## 4. 认证与公共 Header
Manager 调 Agent Manager 时携带:
```http
Authorization: Bearer <service_token>
X-User-ID: <manager_user_id>
X-Binding-Scope: <binding_scope>
X-Correlation-ID: <correlation_id>
X-Idempotency-Key: manager-<manager_deployment_id>
Content-Type: application/json
```
要求:
1. `Authorization` 用于服务间鉴权。
2. `X-Correlation-ID` 必须贯穿 create、callback、approval decision、logs、metrics。
3. `X-Idempotency-Key` 必须支持幂等;重复创建请求不能生成多个真实 Swarm Run。
4. Agent Manager 不得要求 Manager 传长期明文密钥。
## 5. `GET /api/agent/health`
### 响应
```json
{
"success": true,
"data": {
"status": "healthy",
"service": "agent-manager-swarm-runtime",
"version": "1.0.0",
"runtime": "aks",
"time": "2026-05-28T10:00:00Z"
}
}
```
验收:
- HTTP 200。
- `status` 为 `healthy` / `ok` / `up` 之一。
- 不返回密钥、Token、连接串。
## 6. `POST /api/swarms`
### 请求体
```json
{
"orchestration_plan": {
"intent_id": "task_123",
"template_hint": "heicode-task",
"objective": "完成本轮用户目标",
"sub_mode": "agile",
"risk_level": "medium",
"budget": {
"max_tokens": 120000,
"max_cost_usd": 8,
"max_duration_sec": 3600
},
"user_context": {
"user_id": "22",
"channel_id": "heicode",
"binding_scope": "task-task-123"
},
"billing_context": {
"provider": "newapi",
"default_model_id": "model_xxx",
"allowed_model_ids": ["model_xxx"],
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key"
},
"agile_context": {
"iteration": "2026-05-28",
"stage": "development",
"checkpoint": "draft_created",
"acceptance_criteria": [
"接口返回成功",
"artifact 可回写到 timeline",
"不出现明文密钥"
],
"next_action": "continue",
"requires_user_approval": false
},
"agents": [
{
"role_template": "backend",
"goal": "完成后端实现和测试",
"default_model_id": "model_xxx",
"resource_grants": []
}
],
"resource_grants": []
},
"agents": [
{
"role": "backend",
"resource_grants": []
}
],
"resource_grants": [
{
"grant_id": "grant-task-123-git",
"resource_id": "repo-main",
"resource_type": "git",
"permission_scope": ["repo:read", "repo:write:feature-branches"],
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
"ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
"target_role": "backend",
"constraints": {
"allowed_paths": "heicode/**"
},
"metadata": {
"repo": "heicode-manager"
}
}
],
"callback": {
"url": "https://code.xinghanlab.com/api/agent/callbacks/swarm-events",
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agent-callback-signing-key",
"subscribed_events": [
"deployment.status_changed",
"task.created",
"task.claimed",
"task.heartbeat",
"task.blocked",
"task.retried",
"task.failed",
"task.completed",
"handoff.requested",
"handoff.completed",
"approval.requested",
"artifact.created",
"timeline.updated",
"budget.alert"
]
},
"metadata": {
"manager_deployment_id": "dep_xxx",
"heicode_deployment_id": "dep_xxx",
"heicode_runtime_bridge": true,
"correlation_id": "corr_xxx"
}
}
```
### Agent Manager 必须消费的字段
| 字段 | 必须 | 说明 |
|---|---:|---|
| `orchestration_plan.objective` | 是 | 用户目标 |
| `orchestration_plan.sub_mode` | 是 | `agile` / `waterfall`;蜂群执行时也要保留此组织方式 |
| `orchestration_plan.risk_level` | 是 | 高危动作必须走审批 |
| `orchestration_plan.budget` | 是 | token / cost / duration 上限 |
| `billing_context.provider` | 是 | 当前为 `newapi` |
| `billing_context.secret_ref` | 是 | 模型网关密钥引用,只能是 `azkv://...` |
| `agents[].role` 或 `role_template` | 是 | 子 Agent 角色 |
| `resource_grants[]` | 是 | 资源授权清单 |
| `resource_grants[].secret_ref` | 凭据资源必填 | 只允许 Key Vault 引用,不允许明文 |
| `callback.url` | 是 | Runtime 回调 Manager 的地址 |
| `callback.signing_secret_ref` | 建议 | HMAC 签名密钥引用 |
| `metadata.manager_deployment_id` | 是 | Manager 侧 deployment id |
| `metadata.correlation_id` | 是 | 全链路追踪 |
### 响应
```json
{
"success": true,
"data": {
"deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"status": "running",
"created_at": "2026-05-28T10:00:00Z",
"estimated_ready_at": "2026-05-28T10:02:00Z"
}
}
```
兼容响应:
```json
{
"deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"status": "running"
}
```
Manager 映射规则:
| Agent Manager 返回字段 | Manager 保存字段 |
|---|---|
| `data.deployment_id` / `deployment_id` / `id` | `runtime_deployment_id` |
| `data.swarm_id` / `swarm_id` / `runtime_swarm_id` | `runtime_swarm_id` |
| `data.status` / `status` / `runtime_status` | `runtime_state` |
## 7. Runtime 必须生成的 task graph
Agent Manager 创建 Swarm Run 后,必须在 Runtime 内部生成任务图,并通过 callback 回写。
每个 task 至少包含:
```json
{
"task_id": "task-backend-1",
"title": "实现后端接口",
"description": "完成 API、校验和测试",
"agent_role": "backend",
"status": "pending",
"depends_on": ["task-design-1"],
"attempt": 1
}
```
状态建议:
| status | 说明 |
|---|---|
| `pending` | 等待执行 |
| `claimed` | 已被 Agent 领取 |
| `running` | 执行中 |
| `blocked` | 阻塞 |
| `handoff_requested` | 等待交接 |
| `retrying` | 重试中 |
| `completed` | 完成 |
| `failed` | 失败 |
## 8. Runtime 回调 Manager
Manager 回调地址:
```http
POST https://code.xinghanlab.com/api/agent/callbacks/swarm-events
X-Agent-Service-Token: <callback_token>
X-Agent-Event-Id: <event_id>
X-Correlation-ID: <correlation_id>
Content-Type: application/json
```
也支持 HMAC:
```http
X-Agent-Timestamp: <unix_ms>
X-Agent-Signature: sha256=<hex>
```
HMAC 签名内容:
```text
timestamp + "." + event_id + "." + raw_body
```
联调前可拉取 Manager 当前接受的事件 schema:
```http
GET https://code.xinghanlab.com/api/agent/callbacks/swarm-events/schema
```
### 通用 callback envelope
```json
{
"event_id": "evt_123",
"idempotency_key": "evt_123",
"event_type": "task.claimed",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"agent_instance_id": "agent-backend-1",
"task_id": "task-backend-1",
"occurred_at": "2026-05-28T10:10:00Z",
"correlation_id": "corr_xxx",
"source": "agent-manager-runtime",
"payload": {}
}
```
要求:
1. `event_id` 全局唯一。
2. 重试同一事件必须复用相同 `event_id` 或 `idempotency_key`。
3. `deployment_id` 优先使用 Manager deployment id。
4. 如果只知道 `swarm_id`,Manager 也会按 `runtime_swarm_id` 查找 deployment。
5. `source` 要能区分真实 Runtime,例如 `agent-manager-runtime`,不要写 `simulated`。
## 9. 必须回调的事件和字段
| event_type | 必填字段 | 说明 |
|---|---|---|
| `deployment.status_changed` | `payload.status` | Runtime 整体状态变化 |
| `task.created` | `task_id`, `payload.title` | 任务图新增任务 |
| `task.claimed` | `task_id`, `payload.agent_role` | Agent 领取任务 |
| `task.running` | `task_id`, `payload.agent_role` | Agent 开始执行 |
| `task.heartbeat` | `task_id`, `payload.agent_role` | Agent 心跳 |
| `task.blocked` | `task_id`, `payload.reason` | 任务阻塞 |
| `task.retried` | `task_id`, `payload.attempt` | 任务重试 |
| `task.failed` | `task_id`, `payload.reason` | 任务失败 |
| `task.completed` | `task_id` | 任务完成 |
| `handoff.requested` | `task_id`, `payload.from_role`, `payload.to_role` | 请求交接 |
| `handoff.completed` | `task_id`, `payload.from_role`, `payload.to_role` | 完成交接 |
| `artifact.created` | `artifact.artifact_id` 或 `payload.artifact_id` | 产物生成 |
| `approval.requested` | `payload.approval_id`, `payload.operation`, `payload.risk_level` | 高危审批 |
| `timeline.updated` | `payload.title` | 用户可见时间线 |
| `budget.alert` | `payload.threshold_pct` | 预算告警 |
## 10. artifact.created
Runtime 产生中间产物或最终交付物时回调:
```json
{
"event_id": "evt_artifact_1",
"event_type": "artifact.created",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"task_id": "task-backend-1",
"source": "agent-manager-runtime",
"artifact": {
"artifact_id": "art_backend_patch_1",
"artifact_type": "code_patch",
"title": "Backend API patch",
"summary": "新增任务创建和状态查询接口",
"uri": "git://heicode-manager/branches/feature/task-api",
"checksum": "sha256:abc123",
"metadata": {
"agent_role": "backend",
"redacted": true
}
}
}
```
支持的 `artifact_type`:
| 类型 | 说明 |
|---|---|
| `code_patch` | 代码补丁或分支 |
| `document` | 文档 |
| `test_report` | 测试报告 |
| `deployment_manifest` | 部署清单 |
| `log_bundle` | 日志包 |
| `other` | 其他 |
要求:
1. 大文件不要内联到 callback body。
2. `summary` 可展示给用户,不得包含密钥。
3. `uri` 可以是 `git://`、`artifact://`、`azblob://`、`https://`。
4. metadata 必须脱敏。
## 11. approval.requested 与审批结果
高危动作前 Runtime 必须暂停,并回调:
```json
{
"event_id": "evt_approval_1",
"event_type": "approval.requested",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"task_id": "task-deploy-1",
"source": "agent-manager-runtime",
"payload": {
"approval_id": "runtime-approval-1",
"operation": "deploy.production",
"resource_id": "prod-env",
"resource_type": "azure",
"resource_scope": "resource-group/heicode-prod",
"target_role": "ops",
"risk_level": "high",
"requires_credential": true,
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/prod-deploy",
"ttl_seconds": 600,
"reason": "需要部署到生产环境"
}
}
```
Manager / 客户端审批后,Manager 调 Runtime:
```http
POST /api/swarms/{swarm_id}/approvals/{approval_id}
Authorization: Bearer <service_token>
X-Correlation-ID: <correlation_id>
X-Idempotency-Key: approval-decision-<approval_id>-<decision>
Content-Type: application/json
```
请求:
```json
{
"approval_id": "runtime-approval-1",
"decision": "approved",
"reason": "用户已确认",
"manager_deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"decided_by": "user:22",
"decided_at": "2026-05-28T10:20:00Z",
"credential_lease": {
"lease_id": "lease_xxx",
"credential_ref": "lease://runtime/runtime-approval-1",
"expires_at": "2026-05-28T10:30:00Z"
}
}
```
`decision` 枚举:
| decision | Runtime 行为 |
|---|---|
| `approved` | 继续原高危动作 |
| `rejected` | 停止该动作,回调 `timeline.updated` 或 `task.failed` |
要求:
1. Runtime 收到 `approved` 后才可以继续高危动作。
2. Runtime 收到 `rejected` 后不能继续执行该动作。
3. Runtime 不得把 `credential_ref` 展开写入日志或 artifact metadata。
4. Runtime 必须把审批结果后的状态继续通过 callback 回写。
## 12. 日志、指标和用量
### 日志
建议提供:
```http
GET /api/swarms/{swarm_id}/logs?limit=100&cursor=<cursor>
```
响应:
```json
{
"success": true,
"data": {
"items": [
{
"timestamp": "2026-05-28T10:21:00Z",
"level": "info",
"agent_role": "backend",
"task_id": "task-backend-1",
"message": "test completed",
"redacted": true
}
],
"next_cursor": ""
}
}
```
### 指标
建议提供:
```http
GET /api/swarms/{swarm_id}/metrics?window=15m&step=60s
```
响应:
```json
{
"success": true,
"data": {
"swarm_id": "swarm-123",
"agent_metrics": [
{
"agent_instance_id": "agent-backend-1",
"agent_role": "backend",
"task_id": "task-backend-1",
"status": "running",
"cpu_percent": 12.5,
"memory_bytes": 268435456,
"uptime_seconds": 300
}
]
}
}
```
### 用量
Runtime 调模型时必须带关联字段:
| 字段 | 说明 |
|---|---|
| `manager_deployment_id` | Manager deployment id |
| `swarm_id` | Runtime swarm id |
| `task_id` | 当前任务 |
| `agent_role` | Agent 角色 |
| `model_id` | 使用模型 |
| `correlation_id` | 全链路追踪 |
Runtime 可通过 `budget.alert` 或 `timeline.updated` 回传用量摘要。
## 13. 安全约束
必须遵守:
1. 禁止在请求、callback、日志、artifact metadata 中出现明文密码、Token、私钥、连接串、云 access key、模型 key。
2. 长期凭据只能通过 `azkv://<vault>/secrets/<name>` 传引用。
3. Runtime 可以把短期租约写成 `lease://...`,但不能写真实密钥。
4. `secret_ref`、`credential_ref` 只能作为引用使用,不能在用户可见内容中展开。
5. 所有 callback payload 必须脱敏。
6. 高危动作必须先 `approval.requested`,不能先执行后补审批。
## 14. 错误码建议
| HTTP | code | 场景 |
|---:|---|---|
| 401 | `UNAUTHORIZED` | service token 无效 |
| 403 | `FORBIDDEN` | 权限不足 |
| 404 | `SWARM_NOT_FOUND` | swarm 不存在 |
| 409 | `IDEMPOTENCY_CONFLICT` | 幂等键冲突 |
| 422 | `VALIDATION_ERROR` | 请求字段缺失 |
| 422 | `SECRET_REF_INVALID` | `secret_ref` 非 `azkv://...` |
| 422 | `POLICY_REJECTED` | 高危策略拒绝 |
| 500 | `INTERNAL_ERROR` | Runtime 内部错误 |
错误响应:
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "resource_grants[0].secret_ref is required",
"request_id": "corr_xxx"
}
}
```
## 15. 联调验收清单
| 步骤 | 操作 | 通过标准 |
|---:|---|---|
| 1 | Manager 调 `GET /api/agent/health` | 返回 healthy |
| 2 | Manager 调 `POST /api/swarms` | 返回真实 `deployment_id` 和 `swarm_id` |
| 3 | Runtime 回调 `deployment.status_changed` | Manager timeline 可见 |
| 4 | Runtime 回调 `task.created` | Manager task flow / Agent task map 可见 |
| 5 | Runtime 回调 `task.claimed` / `task.heartbeat` | Manager 可看到 Agent 领取和心跳 |
| 6 | Runtime 回调 `handoff.requested` / `handoff.completed` | Manager 可看到交接 |
| 7 | Runtime 回调 `artifact.created` | Manager artifact 列表可见 |
| 8 | Runtime 回调 `approval.requested` | Manager approval 列表出现 pending |
| 9 | 客户端 / Manager approve | Runtime 收到 decision 并继续 |
| 10 | 客户端 / Manager reject | Runtime 收到 decision 并停止对应动作 |
| 11 | Runtime 回传 logs / metrics / usage | Manager 可查询或 timeline 可见 |
| 12 | Runtime 回调最终 `deployment.status_changed=completed` | Manager 和客户端看到完成 |
## 16. 当前不能误报完成的项
以下项只有在 Agent Manager 真实实现并联调后才能算完成:
1. 真实 Swarm Run 创建成功。
2. 真实 task graph 生成。
3. 真实 Agent claim / heartbeat。
4. 真实 handoff / retry / blocked。
5. 真实 artifact 产出。
6. 审批后 Runtime 继续或停止。
7. Runtime 真实日志和指标。
8. 子 Agent 模型用量归属。
9. 最终交付物回到客户端。
Manager 本地模拟事件、测试桩、空态页面和 schema 校验只能证明 Manager 接收和展示能力,不能证明蜂群生产闭环完成。
@@ -1,491 +0,0 @@
# Agent Manager 蜂群 Runtime 联调待确认与补充要求
更新时间:2026-05-29
发给:Agent Manager / HeiCode-Swarm Runtime 负责人
范围:仅针对蜂群模式,不包含普通 sub 敏捷模式。
## 1. 先明确边界
普通 sub 模式和蜂群模式是两套不同的运行时部署,不能混用。
| 模式 | Runtime | 当前已知地址 | 说明 |
|---|---|---|---|
| 普通 sub 敏捷模式 | Agent Manager / Agent Runtime | `http://20.212.121.126` | 用于普通子 Agent 敏捷开发流程 |
| 蜂群模式 | HeiCode-Swarm Orchestrator | `http://52.139.240.116:8000` | 用于蜂群任务图、Agent 协作、handoff、task graph |
注意:两套服务可能都提供 `/api/swarms` 这类路径,但业务含义不同。Heicode Manager 后续需要按模式分别配置,不应只用一套 `AGENT_RUNTIME_BASE_URL` 混跑。
## 2. 本次读取到的蜂群 Runtime 新能力
根据 HeiCode-Swarm 最新 `蜂群对接文档.md` 和代码,蜂群 Runtime 已经补充以下接口:
| 能力 | 路径 | 当前判断 |
|---|---|---|
| 健康检查 | `GET /api/agent/health` | 已提供 |
| 创建蜂群 run | `POST /api/swarms` | 已提供 |
| 兼容创建入口 | `POST /api/agent/deployments` | 已提供 |
| 查询 swarm 详情 | `GET /api/swarms/{swarm_id}` | 已提供 |
| 查询任务图 | `GET /api/swarms/{swarm_id}/tasks` | 已提供 |
| 查询日志 | `GET /api/swarms/{swarm_id}/logs` | 已提供 |
| 查询指标 | `GET /api/swarms/{swarm_id}/metrics` | 已提供 |
| 停止 swarm | `POST /api/swarms/{swarm_id}/stop` | 已提供 |
| 审批结果回传 | `POST /api/swarms/{swarm_id}/approvals/{approval_id}` | 已提供 |
| callback token / HMAC | Runtime callback Manager | 代码支持 |
| `azkv://` 校验 | 请求体 secret 引用 | 代码支持 |
| 幂等创建 | `X-Idempotency-Key` | 代码支持 |
本地契约测试也已确认通过:
```text
python3 scripts/test-runtime-contract.py
runtime contract checks passed
```
## 3. 实测结果
### 3.1 健康检查通过
请求:
```bash
curl http://52.139.240.116:8000/api/agent/health
```
返回核心内容:
```json
{
"success": true,
"data": {
"status": "healthy",
"service": "heicode-swarm-runtime",
"version": "1.0.0",
"runtime": "aks",
"capabilities": [
"swarm.create",
"task.flow",
"handoff.events",
"artifact.events",
"approval.pause_resume",
"deployment.stop",
"runtime.tasks.query",
"runtime.logs.query",
"runtime.metrics.query"
]
}
}
```
### 3.2 创建接口鉴权已通过
Agent Manager / 蜂群 Runtime 已提供当前部署认可的 Runtime Bearer token。使用该 token 后,`POST /api/swarms` 不再返回 401。
```bash
curl -X POST http://52.139.240.116:8000/api/swarms \
-H "Authorization: Bearer <runtime-service-token>" \
-H "Content-Type: application/json" \
-H "X-Correlation-Id: corr_token_check_local" \
-H "X-Idempotency-Key: codex-swarm-token-check" \
-d '<valid-swarm-create-body>'
```
返回:
```json
{
"success": true,
"data": {
"deployment_id": "runtime-dep-e9ef617146fd",
"runtime_deployment_id": "runtime-dep-e9ef617146fd",
"manager_deployment_id": "dep_token_check_local",
"swarm_id": "swarm-64004a6aaf67",
"status": "running",
"created": true
}
}
```
结论:Runtime token 阻塞已解决。具体 token 不写入 Markdown 或 Git。
### 3.3 查询和停止接口已通过
对 `swarm-64004a6aaf67` 查询结果:
| 接口 | 结果 |
|---|---|
| `GET /api/swarms/{swarm_id}` | 成功,返回 Runtime run 详情 |
| `GET /api/swarms/{swarm_id}/tasks` | 成功 |
| `GET /api/swarms/{swarm_id}/logs` | 成功,返回 Runtime event 列表 |
| `GET /api/swarms/{swarm_id}/metrics` | 成功,返回任务数、运行时长、Agent 数等轻量指标 |
| `POST /api/swarms/{swarm_id}/stop` | 成功,状态变为 `stopped` |
### 3.4 新发现:线上蜂群 Runtime 仍只生成单任务
2026-05-29 已按 `HeiCode-Swarm/蜂群对接文档.md` 的结构真实请求线上蜂群 Runtime:
```text
POST http://52.139.240.116:8000/api/swarms
Authorization: Bearer <runtime-service-token>
```
第一组使用文档中的单 Agent 结构,`agents` 放在 `orchestration_plan.agents`,角色为 `backend`。创建成功:
```json
{
"success": true,
"data": {
"runtime_deployment_id": "runtime-dep-fc99510eb2c5",
"manager_deployment_id": "dep_doc_single",
"swarm_id": "swarm-004f1ae3a114",
"status": "running"
}
}
```
但查询任务后,线上 Runtime 没有保留文档请求中的 `backend` 角色,而是生成 `general` 单任务:
```json
{
"task_id": "swarm-004f1ae3a114-task-1",
"title": "Swarm objective",
"agent_role": "general",
"context": {
"workflow_mode": "single_agent",
"allow_handoff": false
}
}
```
第二组使用同样文档结构,但传入 3 个 agents:`planner`、`builder`、`reviewer`。创建成功:
```json
{
"success": true,
"data": {
"runtime_deployment_id": "runtime-dep-ba9de6fe1df3",
"manager_deployment_id": "dep_doc_multi",
"swarm_id": "swarm-ee319f1d4705",
"status": "running"
}
}
```
但查询任务后仍只生成 1 个 `general` 任务:
```json
{
"tasks": [
{
"title": "Swarm objective",
"agent_role": "general",
"status": "pending",
"task_graph_id": "task-1",
"context": {
"workflow_mode": "single_agent",
"allow_handoff": false
}
}
]
}
```
两组测试的日志均只出现 `Created 1 task(s)`,停止接口均返回 `status=stopped`。
这与蜂群设计要求的“动态任务图、能力编队、handoff、协作任务链”仍有差距。当前线上 Runtime 看起来仍是 single-agent fallback 路径,尚未按请求中的 3 个 planner/builder/reviewer 任务生成真实 task graph。
### 3.5 Gitee main 与线上 Runtime 行为不一致
已检查项目地址:
```text
http://gitee.ath.cx:3000/taijibaga/HeiCode-Swarm
```
当前 main commit:
```text
802bbe97ca278d2da3c70a3e471219db62c01c98 对接文档
```
仓库 main 中 `orchestrator/swarm_runtime.py` 的 `build_task_descriptions()` 逻辑会读取:
```python
agents = plan.get("agents") or body.get("agents") or []
```
如果请求里传入 3 个 agents,本地按同样 payload 测试该函数,结果会生成 3 个任务:
```text
plan-1 planner
build-1 builder
verify-1 reviewer
count=3
```
但线上 `http://52.139.240.116:8000` 对同样结构只生成:
```text
task-1 general
workflow_mode=single_agent
allow_handoff=false
```
另外,线上返回中的 `workflow_mode`、`allow_handoff`、`required_capabilities`、`source=runtime_bridge` 等字段,在当前 Gitee main 的 `orchestrator/swarm_runtime.py` 中没有对应代码。
因此当前判断是:
1. 线上 `52.139.240.116:8000` 运行的镜像/代码不是 Gitee main 当前代码;或
2. 线上部署使用了未提交到 Gitee main 的分支/镜像;或
3. 线上通过环境变量或另一套 runtime bridge 强制走 single-agent fallback。
请 Agent Manager / HeiCode-Swarm 负责人确认线上部署的镜像 tag、代码 commit、分支,以及是否启用了 single-agent fallback 配置。
## 4. Agent Manager / 蜂群 Runtime 需要提供或确认
### P0 必须先提供
| 项 | 需要提供 / 确认 | 原因 |
|---|---|---|
| 蜂群 Runtime 调用 token | 已提供并验证可用;后续需要通过安全渠道配置到 Manager,不写入 Git | Manager 调 `POST /api/swarms` 需要 Bearer 鉴权 |
| callback 凭据配置 | Runtime 配置 Manager 认可的 `AGENT_CALLBACK_SERVICE_TOKEN` 或 `AGENT_CALLBACK_SIGNING_SECRET` | Runtime 回调 Manager 必须通过鉴权 |
| 两套部署边界 | 明确普通 sub 继续走 `20.212.121.126`,蜂群走 `52.139.240.116:8000` | 防止普通 sub 和蜂群混用 |
| 创建响应字段 | `deployment_id`、`runtime_deployment_id`、`swarm_id`、`status` 字段保持稳定 | Manager 需要保存映射 |
| callback deployment id 规则 | callback 中 `deployment_id` 应为 Manager deployment id,`runtime_deployment_id` 为 Runtime id,`swarm_id` 为 Runtime swarm id | Manager 根据这些字段落库和展示 |
callback 地址:
```text
https://code.xinghanlab.com/api/agent/callbacks/swarm-events
```
callback schema 查询:
```text
GET https://code.xinghanlab.com/api/agent/callbacks/swarm-events/schema
```
说明:具体 token / signing secret 不应写入 Markdown 或 Git,请通过安全渠道提供。
### P0 端到端能力
| 能力 | 当前情况 | 需要补充或证明 |
|---|---|---|
| Manager 创建蜂群 run | Runtime 直连创建已跑通;Manager 生产尚未切蜂群专用配置 | 将 Runtime token 安全配置到 Manager 后跑真实 Manager 创建 |
| 动态任务图 | 当前线上 Runtime 只生成 1 个 `general` 任务,`workflow_mode=single_agent` | 至少根据目标生成 2-3 个可追踪任务,并回调多条 `task.created` |
| Agent claim / running / heartbeat | 代码里有 dispatch 和事件能力 | 需要真实 Agent 连接、claim、running、heartbeat 证据 |
| handoff | 事件类型和部分逻辑存在 | 需要真实 `handoff.requested` / `handoff.completed` 场景 |
| 失败回流 | 有 `task.blocked`、`task.failed`、`task.retried` 事件类型 | 需要一次真实失败、重试或 blocked 证据 |
| artifact | Agent 返回 `git_branch` 时会发 artifact | 需要真实 `artifact.created` 回 Manager,带 `uri`、`summary`、`artifact_type` |
| 审批暂停 / 恢复 | high risk 会 `waiting_approval`,支持 decision 接口 | 需要真实 `approval.requested` -> Manager approve/reject -> Runtime 继续/阻塞 |
| 日志 | `/logs` 当前主要来自 Runtime 事件 | 需要说明是否提供真实 Agent / Pod 日志,或至少返回可排障日志摘要 |
| 指标 | `/metrics` 返回轻量聚合 | 需要补充真实运行时长、任务耗时、Agent 数、失败数、资源用量 |
| 用量 / 成本 | 只有 Agent result 带 `usage` 时才回传 | 需要真实 NewAPI token、model_cost_usd、runtime_seconds 或明确暂无 |
| callback 可靠性 | 当前代码 callback 失败记录 warning | 需要确认是否有重试、死信、人工重放;没有则列为未完成 |
## 5. 建议双方统一的蜂群创建请求
Heicode Manager 调蜂群 Runtime:
```http
POST http://52.139.240.116:8000/api/swarms
Authorization: Bearer <runtime-service-token>
X-User-ID: <manager-user-id>
X-Binding-Scope: <binding-scope>
X-Correlation-ID: <correlation-id>
X-Idempotency-Key: manager-<manager-deployment-id>
Content-Type: application/json
```
请求体建议:
```json
{
"orchestration_plan": {
"intent_id": "task_xxx",
"objective": "完成本轮用户目标",
"sub_mode": "goal_driven_swarm",
"risk_level": "medium",
"budget": {
"duration_seconds": 3600,
"token_limit": 20000,
"max_cost_usd": 8
},
"user_context": {
"user_id": "22",
"channel_id": "heicode",
"binding_scope": "task-task-xxx"
},
"agents": [
{
"task_id": "plan-1",
"role": "planner",
"title": "目标理解与任务生成",
"description": "整理用户目标、资源约束和验收标准",
"depends_on": []
},
{
"task_id": "build-1",
"role": "builder",
"title": "代码或文档变更",
"description": "根据目标生成可验证交付物",
"depends_on": ["plan-1"]
},
{
"task_id": "verify-1",
"role": "reviewer",
"title": "验证与交付整理",
"description": "检查交付物并形成验收摘要",
"depends_on": ["build-1"]
}
]
},
"callback": {
"url": "https://code.xinghanlab.com/api/agent/callbacks/swarm-events",
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agent-callback-signing-key",
"subscribed_events": [
"deployment.status_changed",
"task.created",
"task.claimed",
"task.running",
"task.heartbeat",
"task.blocked",
"task.retried",
"task.failed",
"task.completed",
"handoff.requested",
"handoff.completed",
"approval.requested",
"artifact.created",
"budget.alert",
"timeline.updated"
]
},
"metadata": {
"manager_deployment_id": "dep_xxx",
"heicode_deployment_id": "dep_xxx",
"correlation_id": "corr_xxx",
"heicode_runtime_bridge": true,
"runtime_mode": "swarm"
},
"billing_context": {
"provider": "newapi",
"default_model_id": "model_xxx",
"allowed_model_ids": ["model_xxx"],
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key"
},
"resource_grants": [
{
"grant_id": "grant-task-git",
"resource_id": "repo-main",
"resource_type": "git",
"permission_scope": ["repo:read", "repo:write:feature-branches"],
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
"ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
"target_role": "builder"
}
]
}
```
## 6. Secret 引用格式需要统一
Heicode Manager 当前正式约束建议使用:
```text
azkv://<vault>/secrets/<name>
```
例如:
```text
azkv://heicode-kv.vault.azure.net/secrets/repo-main
azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key
```
HeiCode-Swarm 文档中示例为:
```text
azkv://heicode/git-write-token
```
建议 Agent Manager / 蜂群 Runtime 确认是否可以接受标准 Key Vault 形式,并在示例文档中同步,避免后续联调时因为 `secret_ref` 格式不一致失败。
## 7. Manager 侧后续配置建议
Heicode Manager 需要保留普通 sub 和蜂群两套 Runtime 配置。建议后续拆成:
```text
# 普通 sub 敏捷模式
AGENT_RUNTIME_BASE_URL=http://20.212.121.126
AGENT_RUNTIME_CREATE_PATH=/api/swarms
# 蜂群模式
SWARM_RUNTIME_BASE_URL=http://52.139.240.116:8000
SWARM_RUNTIME_CREATE_PATH=/api/swarms
SWARM_RUNTIME_STOP_PATH=/api/swarms/{swarm_id}/stop
SWARM_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
SWARM_RUNTIME_SERVICE_TOKEN=<runtime-service-token>
```
如果短期 Manager 还只有一套 `AGENT_RUNTIME_*`,只能临时切换到蜂群 Runtime 做专项联调,不能同时代表普通 sub 和蜂群都在线。
## 8. 蜂群 MVP 验收口径
根据蜂群设计文档和 Heicode 产品资料包,蜂群 MVP 不是“接口能创建一个 run”就完成。最小验收应至少满足:
| 验收项 | 成功标准 |
|---|---|
| 创建 | Manager 调 `POST /api/swarms` 成功,Runtime 返回真实 `swarm_id` |
| 任务图 | Runtime 至少生成 2 个可追踪任务,并可通过 `/tasks` 查询 |
| Agent 执行 | 至少一个真实 Agent claim、running、completed |
| 交接或失败回流 | 至少出现一次 handoff,或一次失败/blocked/retry 并可解释 |
| artifact | 至少 1 个真实 artifact 回 Manager,可在 Manager 查询 |
| 审批 | high risk 流程能 `approval.requested`,Manager approve/reject 后 Runtime 状态变化 |
| 日志 | Manager 或 Runtime 能查到可排障日志,不只是空占位 |
| 指标 | 能看到任务数、状态、耗时、Agent 数、失败数等真实指标 |
| 用量 | 能回传 token、模型成本或明确当前未接真实模型成本 |
| 安全 | 请求、callback、日志、artifact metadata 不出现明文长期密钥 |
| 幂等 | 同一个 `X-Idempotency-Key` 重试不会创建重复 swarm |
| 停止 | Manager stop 后 Runtime 状态为 `stopped`,不继续执行 |
## 9. 建议下一次联调步骤
1. Heicode Manager 新增或临时配置蜂群 Runtime base URL 和 Runtime Bearer token。
2. Agent Manager / 蜂群 Runtime 确认 callback token 或 HMAC secret 已和 Heicode Manager 生产端一致。
3. 使用测试用户通过 Manager 创建蜂群 run。
4. 验证 Manager 保存 `runtime_deployment_id` 和 `runtime_swarm_id`。
5. 验证 Runtime 不再只生成 single-agent fallback,而是生成多任务 task graph。
6. 验证 Runtime 主动 callback:
- `deployment.status_changed`
- `task.created`
- `task.claimed`
- `task.running`
- `task.completed`
- `artifact.created`
- `timeline.updated`
7. 验证查询:
- `GET /api/swarms/{swarm_id}`
- `GET /api/swarms/{swarm_id}/tasks`
- `GET /api/swarms/{swarm_id}/logs`
- `GET /api/swarms/{swarm_id}/metrics`
8. 验证 high risk 审批:
- Runtime 回调 `approval.requested`
- Manager approve
- Runtime 收到 decision 并继续
9. 验证 stop:
- Manager stop
- Runtime stopped
- 不再继续发 running/completed callback
## 10. 当前结论
蜂群 Runtime 当前已经具备可联调的接口骨架和一部分运行时能力,但还没有完成 Heicode 产品设计要求的完整蜂群 MVP 生产验收。
当前最优先阻塞项是:
1. 将已验证可用的蜂群 Runtime Bearer token 通过安全渠道配置到 Heicode Manager。
2. 确认 Runtime 回调 Manager 的 callback token 或 HMAC secret 已生效。
3. 修正线上 Runtime 只生成 1 个 `general` 任务的问题,使其按目标生成真实多任务 task graph。
4. 确认线上镜像/代码 commit 与 Gitee main 是否一致;如果不一致,需要先部署包含多任务 task graph 逻辑的版本。
5. 跑通一次真实 Manager -> 蜂群 Runtime -> Manager callback 的端到端链路。
6. 补齐真实 Agent 执行、handoff/失败回流、artifact、日志、指标、usage/cost 的生产证据。
@@ -1,403 +0,0 @@
# Agent Manager / HeiCode-Swarm 蜂群对接改造请求
更新时间:2026-05-27
发起方:Heicode Manager
用途:给 Agent Manager / HeiCode-Swarm 侧确认需要补齐或对齐的生产接口,便于 Heicode 桌面客户端 -> Manager -> 蜂群 Runtime 跑完整蜂群流程。
## 一、当前 Manager 已准备好的能力
| 能力 | Manager 状态 |
|---|---|
| 用户态创建 | `POST /api/agent/user/deployments`、`POST /api/swarms` 已可创建 Manager deployment |
| Runtime 创建桥接 | 可通过 `AGENT_RUNTIME_BASE_URL` + `AGENT_RUNTIME_CREATE_PATH` 调用 Runtime |
| Runtime 停止桥接 | 可通过 `AGENT_RUNTIME_STOP_PATH` 停止 Runtime deployment |
| 回调接收 | `POST /api/agent/callbacks/swarm-events` 已支持事件、artifact、approval、timeline 回调 |
| 回调安全 | 支持 `X-Agent-Service-Token` 或 `X-Agent-Signature` HMAC |
| 幂等 | `event_id` / `idempotency_key` 去重 |
| 审批结果回传 | 用户 approve/reject 后,Manager 可调用 Runtime approval decision 接口 |
| 密钥边界 | Manager 只传 `secret_ref` / `credential_ref`,不传长期明文密钥 |
## 二、Runtime 需要提供的接口
### 1. 健康检查
```http
GET /api/agent/health
```
如果蜂群侧沿用现有 Orchestrator,也可以临时配置为:
```http
GET /health
```
响应建议:
```json
{
"success": true,
"data": {
"status": "healthy",
"service": "heicode-swarm-runtime",
"version": "x.y.z"
}
}
```
### 2. 创建 Swarm Run
生产目标接口:
```http
POST /api/swarms
Authorization: Bearer <service_token>
X-Correlation-ID: <correlation_id>
X-Idempotency-Key: manager-<deployment_id>
Content-Type: application/json
```
Manager 当前也可兼容配置:
```text
AGENT_RUNTIME_CREATE_PATH=/api/swarms
```
请求核心字段:
```json
{
"orchestration_plan": {
"sub_mode": "agile",
"risk_level": "high",
"user_context": {
"user_id": "22",
"channel_id": "heicode"
},
"billing_context": {
"provider": "newapi",
"default_model_id": "model_xxx",
"allowed_model_ids": ["model_xxx"],
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key"
},
"agents": [],
"resource_grants": []
},
"agents": [
{
"role": "backend",
"resource_grants": []
}
],
"resource_grants": [
{
"grant_id": "grant-task-backend",
"resource_type": "git",
"permission_scope": ["read", "write"],
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main"
}
],
"callback": {
"url": "https://code.xinghanlab.com/api/agent/callbacks/swarm-events",
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agent-callback-signing-key",
"subscribed_events": [
"deployment.status_changed",
"task.created",
"task.claimed",
"task.heartbeat",
"task.completed",
"task.failed",
"handoff.requested",
"approval.requested",
"artifact.created",
"timeline.updated"
]
},
"metadata": {
"manager_deployment_id": "dep_xxx",
"heicode_runtime_bridge": true,
"correlation_id": "corr_xxx"
}
}
```
响应必须返回至少一个 Runtime 标识:
```json
{
"success": true,
"data": {
"deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"status": "created"
}
}
```
兼容要求:
1. `deployment_id` 可作为 Runtime deployment id。
2. `swarm_id` 用于蜂群任务图、审批和事件关联。
3. 如果 Runtime 只返回顶层 JSON,不包 `data`,Manager 也能解析。
4. 不要要求 Manager 发送长期明文密钥。
### 3. 停止 Runtime Run
默认路径:
```http
POST /api/agent/deployments/{deployment_id}/stop
Authorization: Bearer <service_token>
Content-Type: application/json
```
请求:
```json
{
"reason": "Heicode Manager requested stop",
"manager_deployment_id": "dep_xxx"
}
```
响应:
```json
{
"success": true,
"data": {
"deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"status": "stopped"
}
}
```
如果蜂群侧希望使用:
```http
POST /api/swarms/{swarm_id}/stop
```
则 Manager 可通过环境变量配置:
```text
AGENT_RUNTIME_STOP_PATH=/api/swarms/{swarm_id}/stop
```
## 三、Runtime 回调 Manager
统一回调入口:
```http
POST https://code.xinghanlab.com/api/agent/callbacks/swarm-events
X-Agent-Service-Token: <callback_token>
X-Agent-Event-Id: <event_id>
X-Correlation-ID: <correlation_id>
Content-Type: application/json
```
也支持 HMAC:
```http
X-Agent-Timestamp: <unix_ms>
X-Agent-Signature: sha256=<hex>
```
签名内容:
```text
timestamp + "." + event_id + "." + raw_body
```
### 通用事件 envelope
```json
{
"event_id": "evt_123",
"idempotency_key": "evt_123",
"event_type": "task.completed",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"agent_instance_id": "agent-backend-1",
"task_id": "task-backend-1",
"occurred_at": "2026-05-27T10:00:00Z",
"correlation_id": "corr_xxx",
"source": "heicode-swarm-runtime",
"payload": {}
}
```
必须支持的事件类型:
| event_type | 用途 |
|---|---|
| `deployment.status_changed` | Runtime 整体状态变化 |
| `task.created` | 任务图新增任务 |
| `task.claimed` | Agent claim 任务 |
| `task.heartbeat` | Agent 执行心跳 |
| `task.completed` | 任务完成 |
| `task.failed` | 任务失败 |
| `handoff.requested` | 任务交接 |
| `handoff.completed` | 交接完成 |
| `approval.requested` | 请求用户审批 |
| `artifact.created` | 产物生成 |
| `timeline.updated` | 用户可见时间线 |
### artifact 回调
```json
{
"event_id": "evt_artifact_1",
"event_type": "artifact.created",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"task_id": "task-backend-1",
"artifact": {
"artifact_id": "art_backend_patch_1",
"artifact_type": "code_patch",
"title": "Backend patch",
"summary": "完成后端接口修改",
"uri": "git://repo#agent-backend-1-results",
"checksum": "sha256:xxx",
"metadata": {
"commit_sha": "abc123",
"branch": "agent-backend-1-results",
"redacted": true
}
}
}
```
### approval 请求回调
```json
{
"event_id": "evt_approval_1",
"event_type": "approval.requested",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"task_id": "task-deploy-1",
"payload": {
"approval_id": "runtime-approval-1",
"operation": "production_deploy",
"resource_id": "azure-prod",
"resource_type": "cloud_resource",
"resource_scope": "/subscriptions/.../resourceGroups/prod",
"target_role": "ops",
"risk_level": "high",
"requires_credential": true,
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/azure-prod-sp",
"ttl_seconds": 900,
"reason": "部署到生产环境需要用户确认"
}
}
```
## 四、Manager 回传审批结果给 Runtime
Manager 已支持调用该接口。蜂群侧需要实现接收。
默认路径:
```http
POST /api/swarms/{swarm_id}/approvals/{approval_id}
Authorization: Bearer <service_token>
X-Correlation-ID: <correlation_id>
X-Idempotency-Key: approval-decision-<approval_id>-<decision>
Content-Type: application/json
```
Manager 可配置:
```text
AGENT_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
```
请求:
```json
{
"approval_id": "runtime-approval-1",
"decision": "approved",
"manager_deployment_id": "dep_xxx",
"runtime_deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"operation": "production_deploy",
"resource_id": "azure-prod",
"resource_type": "cloud_resource",
"resource_scope": "/subscriptions/.../resourceGroups/prod",
"target_role": "ops",
"risk_level": "high",
"requires_credential": true,
"credential_ref": "lease://agent/lease_xxx",
"lease_id": "lease_xxx",
"lease_expires_at": 1770000000000,
"decided_by": "user:22",
"reason": "用户确认部署",
"decided_at": 1770000000000
}
```
拒绝时:
```json
{
"approval_id": "runtime-approval-1",
"decision": "rejected",
"manager_deployment_id": "dep_xxx",
"runtime_deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"reason": "风险过高",
"decided_by": "user:22",
"decided_at": 1770000000000
}
```
安全要求:
1. Manager 不会在该请求中发送 `secret_ref`。
2. 如果审批通过且需要凭证,只发送短期 `credential_ref = lease://agent/...`。
3. Runtime 不得把 `credential_ref`、service token、签名密钥写入日志或 artifact metadata。
4. Runtime 收到 `approved` 后继续原高危动作;收到 `rejected` 后停止该动作并回调 `timeline.updated` 或 `task.failed/blocked`。
响应:
```json
{
"success": true,
"data": {
"approval_id": "runtime-approval-1",
"status": "accepted"
}
}
```
## 五、当前 HeiCode-Swarm 现状与建议
当前 `HeiCode-Swarm` 可见 Orchestrator 入口是:
| 当前入口 | 可用性 | 建议 |
|---|---|---|
| `GET /health` | 可直接作为健康检查 | Manager `AGENT_RUNTIME_HEALTH_PATH=/health` |
| `POST /tasks` | 可临时创建任务 | 只能做兼容桥接,不建议作为最终生产契约 |
| `GET /tasks` | 可查任务 | 后续需要映射到 callback/timeline |
| `GET /agents` | 可查 Agent | 后续需要回调 agent status |
| `GET /handoffs` | 可查 handoff | 后续需要回调 handoff events |
| `GET /metrics` | 可查指标 | 后续需要按 deployment/swarm 关联 |
| `WS /ws/{agent_id}` | Agent 内部通道 | Manager 不直接接 WS |
建议蜂群侧优先补正式 `POST /api/swarms` 和 approval decision 接口;如果短期只想先跑通,可由 Manager 临时把 create path 指到 `/tasks`,但这会缺少 `secret_ref`、resource grants、approval policy、artifact callback 等正式字段。
## 六、联调验收
最小验收顺序:
1. Manager 调 `GET /api/agent/health` 或 `/health` 返回 healthy。
2. Manager 调 Runtime create,拿到 `runtime_deployment_id` 和 `swarm_id`。
3. Runtime 回调 `deployment.status_changed`,Manager timeline 可见。
4. Runtime 回调 `task.claimed`、`task.completed`,Manager timeline 可见。
5. Runtime 回调 `artifact.created`,Manager artifacts 可见。
6. Runtime 回调 `approval.requested`,Manager/客户端能看到 pending approval。
7. 用户 approve/reject 后,Runtime 收到 approval decision。
8. Runtime 根据 decision 继续或停止,并回调最终结果。
9. 全链路无明文长期密钥、模型 key、云 access key、私钥、连接串。
@@ -1,961 +0,0 @@
# Manager → Agent 平台接口参数文档
**版本**: v0.3(P1/P5 联调契约)
**生效日期**: 2026-05-03
**状态**: 联调准备;当前仓库提供 Manager 侧最小验证端点,生产 Agent 平台部署尚未在本文档中宣称完成。
**方向**: Heicode Manager 主动请求 Agent 平台;Agent 平台返回部署、日志、监控与审计状态。
**范围**: 创建/停止子 Agent 部署、查询部署、获取事件/日志/监控快照、解析 SK 快照、查询审计日志,以及 Agent 辅助 NewAPI 重建/部署的参数约定。
> 2026-05-04 边界修正:Manager 当前不把 `tenant/project` 作为产品、认证或扣费主轴。新请求应使用 `user_context.user_id`、`user_context.channel_id`、`resource_grants[].binding_scope`、`billing_context(newapi)` 和 `agent_runtime(agent)`。本文中仍出现的 `tenant_id/project_id` 只表示旧字段兼容或历史接口命名,不应作为新功能设计依据。
**安全红线**: 请求体只允许传资源元数据、权限范围与 `secret_ref`/环境变量名;不得传明文密码、Token、私钥、连接串或云访问密钥。
> 本文档描述 Manager 对 Agent 平台的出站集成契约。当前仓库中 `/api/agent/*` 是 Manager 侧最小控制面/模拟端点,用于校验同一套 payload 结构;生产接入时,Manager 应将下列请求发送到 Agent 平台网关。
## 0. 概述
本文档按登录接口文档的对接方式组织:先定义接入信息,再逐个接口给出请求、响应、错误和安全约束。接口分组如下:
| 接口 | 用途 | 当前性质 |
|---|---|---|
| `POST /api/agent/deployments` | 创建子 Agent/运维任务部署,含 NewAPI 重建/部署场景 | 必需 |
| `GET /api/agent/deployments` | 查询部署列表 | 必需 |
| `GET /api/agent/deployments/{deployment_id}` | 查询单个部署详情 | 必需 |
| `POST /api/agent/deployments/{deployment_id}/stop` | 停止部署或取消排队任务 | 必需 |
| `GET /api/agent/deployments/{deployment_id}/logs` | 拉取部署日志 | 必需 |
| `GET /api/agent/deployments/{deployment_id}/logs/stream` | 实时日志 SSE | 可选 |
| `GET /api/agent/projects/{project_id}/dashboard-snapshot` | 资源作用域监控快照;路径名保留旧兼容,参数值按 `binding_scope` 解释 | 必需 |
| `GET /api/agent/deployments/{deployment_id}/metrics` | 单部署指标序列 | 建议 |
| `GET /api/agent/deployments/{deployment_id}/events` | 部署事件 | 必需 |
| `GET /api/agent/audit-logs` | 审计日志 | 必需 |
| `POST /api/agent/sk-snapshots/resolve` | 触发 SK 快照解析 | 必需 |
| `GET /api/agent/deployments/{deployment_id}/sk-snapshots` | 查询 SK 快照 | 必需 |
> 不在本文档范围:真实 Secret Store 写入、生产 SSH 登录、云账号授权回调、NewAPI 管理后台开放。生产部署动作只有实际执行并通过日志/监控/审计验证后,才能在报告中标记为“已部署”。
---
## 1. 接入约定
### 1.1 Base URL
由部署环境配置,不写入仓库。例如:
```text
AGENT_PLATFORM_BASE_URL=https://agent-platform.example.com
```
联调环境建议使用独立域名或内网网关,示例不得包含真实凭据:
```text
AGENT_PLATFORM_BASE_URL=https://staging-agent.example.com
MANAGER_SERVICE_TOKEN_SECRET_REF=azkv://heicode-kv.vault.azure.net/secrets/manager-service-agent-platform-service-token
```
完整路径示例:
```http
POST https://agent-platform.example.com/api/agent/deployments
```
### 1.2 通用 Header
| Header | 必填 | 说明 |
|---|---:|---|
| `Authorization: Bearer <manager-service-token>` | 是 | Manager 服务身份令牌,由 Secret Store/运行环境注入。 |
| `Content-Type: application/json` | POST/PUT 是 | JSON 请求体。 |
| `X-User-Id: <user_id>` | 建议 | 登录用户边界;也可从 Manager 服务端 token 或 body `user_context.user_id` 推导。 |
| `X-Binding-Scope: <binding_scope>` | 建议 | Git/SK/云资源作用域;也可从 `resource_grants[].binding_scope` 推导。 |
| `X-Correlation-Id: <uuid>` | 是 | Manager 生成,全链路追踪。 |
| `X-Request-Id: <uuid>` | 建议 | 单次 HTTP 请求追踪 ID,可与 correlation_id 不同。 |
| `Idempotency-Key: <uuid>` | 创建类接口建议 | 避免重试造成重复部署。 |
### 1.3 通用响应包裹
成功:
```json
{
"success": true,
"data": {}
}
```
失败:
```json
{
"success": false,
"message": "human readable message",
"error": {
"code": "POLICY_REJECTED",
"message": "human readable message",
"request_id": "req_xxx"
}
}
```
建议错误码:
| code | 场景 |
|---|---|
| `POLICY_REJECTED` | 缺必填字段、权限策略不满足、风险等级非法。 |
| `BUDGET_EXCEEDED` | 超过 token/金额/时长预算。 |
| `MODEL_NOT_ALLOWED` | agent 默认模型不在允许列表内。 |
| `FORBIDDEN_SCOPE` | Header 与 body/query 的用户或资源作用域不一致。 |
| `RESOURCE_GRANT_INVALID` | Resource Grant 字段缺失、跨用户/跨资源作用域/角色不匹配。 |
| `RESOURCE_GRANT_SECRET_REF_REQUIRED` | 凭据型资源缺少 `secret_ref`。 |
| `RESOURCE_GRANT_SECRET_REJECTED` | 请求中出现明文密钥字段。 |
| `SK_SOURCE_UNRESOLVABLE` | SK 来源不可解析。 |
| `DEPLOYMENT_CONFLICT` | 部署不存在、状态冲突或重复提交。 |
| `NOT_FOUND` | deployment、agent instance、snapshot 或审计资源不存在。 |
| `CURSOR_EXPIRED` | 分页游标过期或不属于当前查询条件。 |
| `RATE_LIMITED` | 平台限流;响应头建议包含 `Retry-After`。 |
| `INTERNAL_ERROR` | 平台内部错误;Manager 应记录 request_id 并重试或提示稍后处理。 |
### 1.4 通用错误处理
| HTTP | business code | Manager 处理建议 |
|---:|---|---|
| 400 | `POLICY_REJECTED` / `RESOURCE_GRANT_INVALID` | 标记部署失败,展示校验原因,不自动重试。 |
| 401 | `UNAUTHORIZED` | 检查 Manager 服务令牌的 `secret_ref`/环境注入,不把令牌写入日志。 |
| 403 | `FORBIDDEN_SCOPE` / `MODEL_NOT_ALLOWED` | 阻断本次部署,写审计事件。 |
| 404 | `NOT_FOUND` | 对查询类接口返回空态;对控制类接口提示资源不存在。 |
| 409 | `DEPLOYMENT_CONFLICT` | 使用 `Idempotency-Key` 查询既有结果,避免重复创建。 |
| 410 | `CURSOR_EXPIRED` | 丢弃 cursor,使用 `since` 重新拉取。 |
| 422 | `RESOURCE_GRANT_SECRET_REF_REQUIRED` / `RESOURCE_GRANT_SECRET_REJECTED` | 要求 Manager 重新生成只含 `secret_ref` 的 payload。 |
| 429 | `RATE_LIMITED` | 按 `Retry-After` 退避重试。 |
| 500/503 | `INTERNAL_ERROR` | 指数退避重试;超过阈值后转人工排查。 |
---
## 2. 创建子 Agent 部署
### 2.1 Endpoint
```http
POST /api/agent/deployments
```
### 2.2 请求体
```json
{
"orchestration_plan": {
"intent_id": "intent_20260502_001",
"template_hint": "manager-resource-binding",
"objective": "为已绑定的代码仓库启动 builder 子 Agent,允许其读取 SK 并在限定路径内提交代码",
"risk_level": "low",
"budget": {
"max_tokens": 100000,
"max_cost_usd": 30,
"max_duration_sec": 7200
},
"user_context": {
"user_id": "user_123",
"email": "user@example.com",
"role": "user",
"channel_id": "channel_abc",
"subscription_tier": "pro"
},
"billing_context": {
"provider": "newapi",
"newapi_user_ref": "newapi_user_123",
"newapi_group": "development",
"quota_ref": "newapi_token_or_group_quota_ref"
},
"agent_runtime": {
"platform": "agent",
"agents": [
{
"role": "builder",
"model_ref": "agent_model_profile_builder",
"instance_count": 1
}
]
},
"constraints": {
"allowed_model_ids": ["gpt-5.4-mini", "gpt-5.4"]
},
"metadata": {
"tenant_id": "legacy-user-scope",
"project_id": "legacy-resource-scope",
"correlation_id": "corr_20260502_001"
},
"agents": [
{
"role_template": "builder",
"goal": "按 Manager 下发的任务在允许资源内完成实现、验证并回传状态",
"default_model_id": "gpt-5.4-mini",
"sk_sources": [
{
"type": "git",
"mime": "text/markdown",
"repo_ref": {
"connection_id": "conn_sk_repo_001",
"repo_url": "https://example.com/org/sk-repo.git",
"ref": "main",
"paths": ["skills/heicode/**", "AGENTS.md"]
}
}
],
"runtime_execution": {
"profile_id": "aks-codex-standard",
"cloud_principal_refs": ["principal://users/user_123/agent-runtime"],
"network_policy_ref": "netpol://bindings/repo_default/restricted-egress"
},
"sk_access_policy": {
"policy_ref": "sk-policy://bindings/repo_default/default-readonly",
"deny_skill_ids": ["dangerous-shell"],
"inherit_deployment_defaults": true
},
"resource_grants": [
{
"grant_id": "grant_git_repo_001",
"resource_id": "res_git_repo_001",
"resource_type": "git",
"user_id": "user_123",
"binding_scope": "repo_default",
"tenant_id": "legacy-user-scope",
"project_id": "legacy-resource-scope",
"target_role": "builder",
"target_agent_ref": "agent-builder-1",
"permission_scope": ["repo:read", "repo:write:current-branch"],
"constraints": {
"ref": "main",
"allowed_paths": "heicode/**,docs/**",
"forbid_branch_create": "true"
},
"metadata": {
"provider": "gitee",
"repo_url": "https://example.com/org/repo.git"
},
"status": "active",
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/user-123-repo-default-res-git-repo-001",
"audit": {
"created_by": "manager",
"approval_id": "approval_001"
}
},
{
"grant_id": "grant_doc_001",
"resource_id": "res_project_doc_001",
"resource_type": "project_doc",
"user_id": "user_123",
"binding_scope": "repo_default",
"tenant_id": "legacy-user-scope",
"project_id": "legacy-resource-scope",
"target_role": "builder",
"target_agent_ref": "agent-builder-1",
"permission_scope": ["doc:read"],
"constraints": {
"doc_paths": "docs/heicode.md,docs/plan.md"
},
"metadata": {
"doc_ref": "project-doc://project-a/docs-mainline"
},
"status": "active",
"audit": {
"created_by": "manager"
}
}
]
}
]
}
}
```
### 2.3 字段说明
#### orchestration_plan
| 字段 | 类型 | 必填 | 约束/说明 |
|---|---|---:|---|
| `intent_id` | string | 是 | Manager 侧意图 ID,用于幂等、审计和追踪。 |
| `template_hint` | string | 是 | Agent 平台选择编排模板的提示,如 `manager-resource-binding`。 |
| `objective` | string | 是 | 本次部署目标,应是自然语言但不能含密钥。 |
| `risk_level` | enum | 是 | `low` / `medium` / `high`。高风险必须携带客户端审批证据;缺失或不匹配时只能只读或拒绝执行。 |
| `budget.max_tokens` | int | 是 | 当前策略上限建议不超过 `500000`。 |
| `budget.max_cost_usd` | number | 是 | 当前策略上限建议不超过 `200`。 |
| `budget.max_duration_sec` | int | 是 | 当前策略上限建议不超过 `86400`。 |
| `user_context` | object | 建议 | 登录用户上下文,优先使用 Heicode/Agent 登录返回的 `user.id`/`channelId`。 |
| `billing_context` | object | 条件 | NewAPI 扣费上下文;只表达 user/token/group/quota 映射,不表达子 Agent 模型或实例数。 |
| `agent_runtime` | object | 条件 | Agent 平台运行时上下文;表达子 Agent 角色、模型 profile 和实例数,不承载 NewAPI key 或扣费对象。 |
| `constraints.allowed_model_ids` | string[] | 否 | agent 的 `default_model_id` 如填写,必须在此列表内。 |
| `metadata.tenant_id` | string | 否 | 旧兼容字段;新实现不得作为产品租户边界。 |
| `metadata.project_id` | string | 否 | 旧兼容字段;新实现不得作为项目账本边界。 |
| `metadata.correlation_id` | string | 是 | 全链路追踪 ID。 |
| `agents` | array | 是 | 至少 1 个子 Agent。 |
#### user_context / billing_context / agent_runtime
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `user_context.user_id` | string | 建议 | Manager 业务用户 ID,来自登录接口 `id` 或 JWT `sub`。 |
| `user_context.channel_id` | string | 建议 | 用于关联 NewAPI 用户、Token、Group、余额或额度策略。 |
| `billing_context.provider` | enum | 条件 | 当前只允许 `newapi`。设置后必须提供 `channel_id`、`newapi_user_ref`、`newapi_group` 或 `quota_ref` 之一。 |
| `billing_context.newapi_user_ref` | string | 否 | NewAPI 用户映射引用,不是 NewAPI key。 |
| `billing_context.newapi_group` | string | 否 | NewAPI Group 映射,用于额度或策略选择。 |
| `billing_context.quota_ref` | string | 否 | Token 或 Group 额度引用,不得包含真实 Token 原文。 |
| `agent_runtime.platform` | enum | 条件 | 当前只允许 `agent`。 |
| `agent_runtime.agents[].role` | string | 条件 | 必须匹配 `agents[].role_template`。 |
| `agent_runtime.agents[].model_ref` | string | 条件 | Agent 平台模型 profile 引用;不是 NewAPI 扣费字段。 |
| `agent_runtime.agents[].instance_count` | int | 条件 | 子 Agent 实例数量,必须大于 0。 |
#### agents[]
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `role_template` | string | 是 | 平台角色模板,如 `builder`/`reviewer`/`operator`。 |
| `goal` | string | 是 | 此 agent 的任务目标。 |
| `default_model_id` | string | 否 | 默认模型;若设置需满足 allowed_model_ids。 |
| `sk_sources` | array | 否 | SK 来源,平台应解析为只读快照。 |
| `runtime_execution` | object | 否 | 运行环境绑定;任一字段存在时 `profile_id` 必填。 |
| `sk_access_policy` | object | 否 | SK 权限策略。若 `deny_skill_ids` 非空,需 `policy_ref` 或继承默认策略。 |
| `resource_grants` | array | 否 | Manager 下发给子 Agent 的最小权限资源授权。 |
#### sk_sources[]
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `type` | enum | 否 | 空值表示不启用;支持 `git`、`upload`。 |
| `artifact_id` | string | upload 必填 | 上传型 SK 包 ID。 |
| `mime` | string | 否 | 内容类型,如 `text/markdown`。 |
| `repo_ref.connection_id` | string | git 建议 | Git 连接资源 ID。 |
| `repo_ref.repo_url` | string | git 建议 | 仓库 URL;不得带用户名密码。 |
| `repo_ref.ref` | string | git 必填 | 分支/标签/commit。 |
| `repo_ref.paths` | string[] | git 必填 | 允许读取的路径。 |
#### runtime_execution
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `profile_id` | string | 条件必填 | Agent 平台运行规格,如 AKS profile。 |
| `cloud_principal_refs` | string[] | 否 | 运行身份引用,不是明文凭据。 |
| `network_policy_ref` | string | 否 | 网络策略引用,用于限制出站/入站。 |
#### resource_grants[]
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `grant_id` | string | 是 | 授权记录 ID。 |
| `resource_id` | string | 是 | Manager 资源 ID。 |
| `resource_type` | enum | 是 | `git` / `sk` / `project_doc` / `cloud_account` / `cloud_resource`。 |
| `user_id` | string | 建议 | 与 `user_context.user_id` 一致;为空时平台可从部署上下文推导。 |
| `binding_scope` | string | 是 | Git/SK/云资源作用域,例如 repo/ref/path 或云资源引用。 |
| `tenant_id` | string | 否 | 旧兼容字段;新实现不应依赖。 |
| `project_id` | string | 否 | 旧兼容字段;新实现不应依赖。 |
| `target_role` | string | 是 | 必须等于当前 agent 的 `role_template`。 |
| `target_agent_ref` | string | 是 | Manager 侧对子 Agent 的逻辑引用。 |
| `permission_scope` | string[] | 是 | 最小权限列表,如 `repo:read`、`doc:read`。 |
| `constraints` | object<string,string> | 否 | 路径、分支、区域、超时等限制;不得含密钥字段。 |
| `metadata` | object<string,string> | 否 | 资源展示/审计元数据;不得含密钥字段。 |
| `status` | enum | 是 | `pending` / `active` / `disabled` / `revoked`。 |
| `secret_ref` | string | 条件必填 | `git`、`sk`、`cloud_account`、`cloud_resource` 必填;`project_doc` 可为空。 |
| `audit` | object<string,string> | 否 | 审计上下文;不得含密钥字段。 |
### 2.4 典型场景:Agent 辅助 NewAPI 重建/部署
当 Manager 需要让 Agent 平台协助重建或部署 NewAPI 时,仍使用 `POST /api/agent/deployments`,但必须把任务表达为受控运维部署,不得把 VM、PostgreSQL、Redis、NewAPI key 等真实凭据写入请求体。
请求体示例:
```json
{
"orchestration_plan": {
"intent_id": "intent_newapi_rebuild_20260503_001",
"template_hint": "newapi-rebuild-deploy",
"objective": "在批准窗口内重建 NewAPI 服务并回传健康检查、日志与资源使用摘要",
"risk_level": "high",
"budget": {
"max_tokens": 80000,
"max_cost_usd": 20,
"max_duration_sec": 3600
},
"constraints": {
"allowed_model_ids": ["gpt-5.4", "gpt-5.4-mini"],
"requires_approval": "true",
"rollback_required": "true",
"healthcheck_url_ref": "env://NEWAPI_HEALTHCHECK_URL"
},
"metadata": {
"tenant_id": "legacy-user-scope",
"project_id": "legacy-newapi-scope",
"correlation_id": "corr_newapi_20260503_001",
"service": "new-api",
"environment": "production",
"commit": "origin/main",
"runbook_ref": "heicode/docs/deploy/new-api-rebuild-deploy-runbook.md"
},
"agents": [
{
"role_template": "operator",
"goal": "按 runbook 执行构建、服务重启、健康检查和失败回滚;只通过 secret_ref/env 获取凭据",
"default_model_id": "gpt-5.4",
"runtime_execution": {
"profile_id": "aks-codex-ops",
"cloud_principal_refs": ["principal://users/user_123/agent-ops"],
"network_policy_ref": "netpol://bindings/newapi-prod/ops-egress"
},
"resource_grants": [
{
"grant_id": "grant_newapi_vm_ops",
"resource_id": "res_newapi_vm",
"resource_type": "cloud_resource",
"user_id": "user_123",
"binding_scope": "newapi-prod",
"tenant_id": "legacy-user-scope",
"project_id": "legacy-newapi-scope",
"target_role": "operator",
"target_agent_ref": "agent-operator-1",
"permission_scope": ["vm:ssh:approved-window", "service:restart", "log:read", "healthcheck:read"],
"constraints": {
"approval_id": "approval_newapi_001",
"window": "2026-05-03T10:00:00Z/2026-05-03T11:00:00Z",
"rollback_command_ref": "runbook://newapi/rollback"
},
"metadata": {
"host_ref": "env://NEWAPI_VM_HOST",
"service_name": "new-api"
},
"status": "active",
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/user-123-newapi-prod-res-newapi-vm",
"audit": {
"created_by": "manager",
"approval_id": "approval_newapi_001"
}
},
{
"grant_id": "grant_newapi_runtime_env",
"resource_id": "res_newapi_runtime_env",
"resource_type": "cloud_resource",
"user_id": "user_123",
"binding_scope": "newapi-prod",
"tenant_id": "legacy-user-scope",
"project_id": "legacy-newapi-scope",
"target_role": "operator",
"target_agent_ref": "agent-operator-1",
"permission_scope": ["env:read:runtime", "secret:read:scoped"],
"constraints": {
"allowed_env_refs": "DATABASE_DSN,REDIS_URL,NEWAPI_SERVICE_TOKEN",
"plaintext_export_forbidden": "true"
},
"metadata": {
"secret_provider": "azure_key_vault",
"scope": "newapi-runtime"
},
"status": "active",
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/user-123-newapi-prod-res-newapi-runtime-env",
"audit": {
"created_by": "manager",
"approval_id": "approval_newapi_001"
}
}
]
}
]
}
}
```
Agent 平台返回的部署详情、日志、监控和审计中应至少能证明:构建版本/commit、服务重启结果、健康检查结果、资源使用情况、失败回滚状态。未执行真实 SSH/生产动作时,只能返回 `phase=planned` 或 `phase=pending_approval`。
NewAPI 重建/部署的完成判定必须同时满足:
1. `metadata.commit` 或部署详情中的 resolved commit 已在 VM 仓库中生效。
2. 部署日志包含构建/compose/restart 的脱敏摘要。
3. 健康检查返回成功,且监控接口能返回本次 deployment 的状态或资源摘要。
4. 审计日志包含高风险审批 ID、执行者、目标环境和结果。
5. 回滚指针或回滚命令引用已记录。
### 2.5 成功响应
```json
{
"success": true,
"data": {
"deployment_id": "dep_abc123",
"status": "accepted",
"agent_instances": [
{
"instance_id": "agi_abc123",
"role": "builder",
"phase": "pending"
}
]
}
}
```
---
## 3. 部署状态与控制接口
### 3.1 查询部署列表
```http
GET /api/agent/deployments?user_id=user_123&binding_scope=repo_default
```
返回:
```json
{
"success": true,
"data": {
"items": [
{
"deployment_id": "dep_abc123",
"status": "accepted",
"phase": "pending",
"created_at": "2026-05-02T00:00:00Z",
"updated_at": "2026-05-02T00:00:00Z"
}
],
"total": 1
}
}
```
### 3.2 查询单个部署
```http
GET /api/agent/deployments/{deployment_id}
```
返回应包含部署状态、phase、agent_instances、最近错误、资源授权摘要和预算消耗摘要。Agent 平台返回时必须对 `secret_ref` 以外的凭据信息做脱敏;原则上不返回任何明文凭据。
### 3.3 停止部署
```http
POST /api/agent/deployments/{deployment_id}/stop
```
请求体可为空;如需原因可扩展:
```json
{
"reason": "user_requested",
"requested_by": "manager"
}
```
成功:
```json
{
"success": true,
"data": {
"deployment_id": "dep_abc123",
"status": "stopped"
}
}
```
错误与幂等:
| HTTP | business code | 说明 |
|---:|---|---|
| 200 | - | 已停止的部署重复停止也可返回 200,并保持 `status=stopped`。 |
| 404 | `NOT_FOUND` | 部署不存在或不属于当前租户/项目。 |
| 409 | `DEPLOYMENT_CONFLICT` | 部署已进入不可停止的终态,如 `completed` 且无运行实例。 |
| 422 | `POLICY_REJECTED` | 高风险停止缺少审批记录或 reason 不合法。 |
若停止请求触发异步回收,平台可返回 `status=stopping`;Manager 应继续通过事件、日志和监控接口确认最终状态。
---
## 4. 日志接口(Manager 拉取 Agent 平台)
### 4.1 获取部署日志
```http
GET /api/agent/deployments/{deployment_id}/logs?agent_instance_id=agi_abc123&stream=stdout&since=2026-05-02T00:00:00Z&limit=200&cursor=cur_001
```
Query:
| 参数 | 必填 | 说明 |
|---|---:|---|
| `agent_instance_id` | 否 | 不传则返回该 deployment 下全部实例日志。 |
| `stream` | 否 | `stdout` / `stderr` / `system` / `audit`,默认全部。 |
| `since` | 否 | RFC3339 时间,增量拉取起点。 |
| `limit` | 否 | 默认 200,建议最大 1000。 |
| `cursor` | 否 | 分页游标。 |
响应:
```json
{
"success": true,
"data": {
"items": [
{
"log_id": "log_001",
"deployment_id": "dep_abc123",
"agent_instance_id": "agi_abc123",
"stream": "stdout",
"level": "info",
"message": "task started",
"redacted": true,
"occurred_at": "2026-05-02T00:00:01Z"
}
],
"next_cursor": "cur_002",
"total": 1
}
}
```
日志要求:
- Agent 平台必须在返回前完成密钥脱敏。
- `message` 不得包含密码、Token、私钥、连接串、云访问密钥。
- Manager 只保存必要摘要和审计索引;长日志建议落对象存储并设置保留期。
### 4.2 实时日志流(可选)
```http
GET /api/agent/deployments/{deployment_id}/logs/stream?agent_instance_id=agi_abc123
Accept: text/event-stream
```
SSE event 示例:
```text
event: log
data: {"log_id":"log_002","level":"info","message":"step completed","occurred_at":"2026-05-02T00:00:02Z"}
```
SSE 事件类型:
| event | 说明 |
|---|---|
| `log` | 普通日志行,必须已脱敏。 |
| `heartbeat` | 保活事件,建议 15-30 秒一次。 |
| `error` | 流式读取错误;不包含敏感上下文。 |
| `done` | 部署进入终态或服务端主动结束流。 |
错误处理:
| HTTP | business code | Manager 处理建议 |
|---:|---|---|
| 401/403 | `UNAUTHORIZED` / `FORBIDDEN_SCOPE` | 立即断开流并记录审计。 |
| 404 | `NOT_FOUND` | 停止订阅并刷新部署详情。 |
| 429 | `RATE_LIMITED` | 退避后重连,保留 `Last-Event-Id`。 |
---
## 5. 监控接口(Manager 拉取 Agent 平台)
### 5.1 项目监控快照
```http
GET /api/agent/projects/{binding_scope}/dashboard-snapshot?window=1h
```
响应:
```json
{
"success": true,
"data": {
"project_id": "project-a",
"active_instances": 3,
"phase_distribution": {
"pending": 1,
"running": 2,
"stopped": 0,
"failed": 0
},
"failure_rate_1h": 0.02,
"avg_task_duration": 185.3,
"budget": {
"tokens_used": 32000,
"cost_usd": 4.21,
"duration_sec": 930
},
"resource_usage": {
"cpu_millicores": 1200,
"memory_mb": 2048,
"network_rx_bytes": 102400,
"network_tx_bytes": 204800
},
"updated_at": "2026-05-02T00:05:00Z"
}
}
```
### 5.2 单部署监控快照(建议平台实现)
```http
GET /api/agent/deployments/{deployment_id}/metrics?window=15m&step=60s
```
响应:
```json
{
"success": true,
"data": {
"deployment_id": "dep_abc123",
"window": "15m",
"series": [
{
"metric": "tokens_used",
"unit": "count",
"points": [["2026-05-02T00:00:00Z", 1200]]
},
{
"metric": "cpu_millicores",
"unit": "millicore",
"points": [["2026-05-02T00:00:00Z", 500]]
}
]
}
}
```
建议指标:`tokens_used`、`cost_usd`、`duration_sec`、`cpu_millicores`、`memory_mb`、`restart_count`、`tool_call_count`、`error_count`、`queue_latency_ms`。
---
## 6. 事件与审计接口
### 6.1 部署事件
```http
GET /api/agent/deployments/{deployment_id}/events?since=2026-05-02T00:00:00Z&limit=200&cursor=cur_001
```
响应:
```json
{
"success": true,
"data": {
"items": [
{
"event_id": "evt_001",
"event": "deployment.accepted",
"schema_version": 1,
"user_id": "user_123",
"channel_id": "channel_abc",
"binding_scope": "repo_default",
"deployment_id": "dep_abc123",
"correlation_id": "corr_20260502_001",
"occurred_at": "2026-05-02T00:00:00Z"
}
],
"next_cursor": "cur_002",
"total": 1
}
}
```
常用事件名:
| event | 说明 |
|---|---|
| `deployment.accepted` | 平台接受部署请求。 |
| `instance.phase_changed` | 子 Agent phase 变化。 |
| `sk_snapshot_refreshed` | SK 快照解析/刷新完成。 |
| `resource_grant.attached` | 资源授权已绑定到实例。 |
| `resource_grant.revoked` | 授权被撤销或禁用。 |
| `budget.threshold_reached` | 预算阈值触发。 |
| `deployment.failed` | 部署失败。 |
### 6.2 审计日志
```http
GET /api/agent/audit-logs?user_id=user_123&binding_scope=repo_default&actor=agent_control_plane&action=deployment.accepted&since=2026-05-02T00:00:00Z&limit=200&cursor=cur_001
```
响应:
```json
{
"success": true,
"data": {
"items": [
{
"audit_id": "aud_001",
"actor": "agent_control_plane",
"action": "deployment.accepted",
"resource": "dep_abc123",
"user_id": "user_123",
"channel_id": "channel_abc",
"binding_scope": "repo_default",
"request_id": "req_001",
"correlation_id": "corr_20260502_001",
"result": "ok",
"occurred_at": "2026-05-02T00:00:00Z"
}
],
"next_cursor": "cur_002",
"total": 1
}
}
```
---
## 7. SK 快照接口
### 7.1 触发解析/刷新
```http
POST /api/agent/sk-snapshots/resolve
```
请求:
```json
{
"deployment_id": "dep_abc123"
}
```
响应:
```json
{
"success": true,
"data": {
"deployment_id": "dep_abc123",
"items": [
{
"snapshot_id": "sks_001",
"deployment_id": "dep_abc123",
"user_id": "user_123",
"binding_scope": "repo_default",
"source_type": "git",
"source_ref": "main:skills/heicode/**@sha_xxx",
"resolved_at": "2026-05-02T00:00:00Z"
}
],
"total": 1
}
}
```
### 7.2 查询部署 SK 快照
```http
GET /api/agent/deployments/{deployment_id}/sk-snapshots
```
Query:
| 参数 | 必填 | 说明 |
|---|---:|---|
| `user_id` | 建议 | 与 `X-User-Id` 或 deployment 的 `user_context.user_id` 一致。 |
| `binding_scope` | 建议 | 与 `X-Binding-Scope` 或 deployment 的资源授权作用域一致。 |
| `source_type` | 否 | `git` / `upload`,用于筛选。 |
| `limit` | 否 | 默认 100,最大 500。 |
| `cursor` | 否 | 分页游标。 |
成功响应:
```json
{
"success": true,
"data": {
"items": [
{
"snapshot_id": "sks_001",
"deployment_id": "dep_abc123",
"user_id": "user_123",
"binding_scope": "repo_default",
"source_type": "git",
"source_ref": "main:skills/heicode/**@sha_xxx",
"artifact_ref": "artifact://bindings/repo_default/sk/sks_001",
"checksum": "sha256:example-redacted",
"status": "ready",
"resolved_at": "2026-05-02T00:00:00Z"
}
],
"next_cursor": "cur_002",
"total": 1
}
}
```
错误处理:
| HTTP | business code | 说明 |
|---:|---|---|
| 404 | `NOT_FOUND` | deployment 不存在或不属于当前租户/项目。 |
| 410 | `CURSOR_EXPIRED` | cursor 过期,使用不带 cursor 的查询重拉。 |
| 422 | `SK_SOURCE_UNRESOLVABLE` | 快照来源不可解析,详情在事件/日志中查看。 |
---
## 8. 安全校验清单
Manager 发给 Agent 平台前必须执行:
1. `user_id`、`binding_scope`、`target_role` 与部署计划一致。
2. 凭据型资源只传 `secret_ref`,不传明文凭据。
3. `metadata`、`constraints`、`audit` 的 key 中不得出现 `password`、`token`、`secret`、`private_key`、`access_key`、`credential` 等敏感词。
4. `repo_url` 不得包含用户名、密码或访问 Token。
5. `permission_scope` 使用最小权限;生产写操作必须携带客户端审批记录,Agent 平台不得自行补批。
6. 高风险操作(生产部署、云资源修改、删除、扩容)必须设置 `risk_level=high`,Agent 平台执行前只校验客户端审批证据。
7. 所有日志/事件/审计返回给 Manager 前必须脱敏。
Agent 平台不承担高危操作审批主体。审批只发生在客户端;Agent 平台只能在执行前校验以下字段和策略是否一致:
| 校验项 | 要求 |
|---|---|
| `approval_id` | 必须存在于高危任务的 `constraints` 或 `audit`,并可追溯到客户端审批记录。 |
| 审批主体 | 审批用户必须与 `user_context.user_id`、`resource_grants[].user_id` 或授权代理主体一致。 |
| 审批范围 | 审批范围必须覆盖 `binding_scope`、`permission_scope`、目标环境、资源 ID 和操作类型。 |
| TTL / 时间窗口 | 审批记录必须未过期;若使用 `window` 或 TTL,当前执行时间必须落在允许范围内。 |
| `risk_level` | 高危资源写入、生产部署、云资源修改、删除和扩容必须为 `high`。 |
| 策略 | 平台 policy、Key Vault 访问策略、Kubernetes/Workload Identity、网络策略和最小权限约束均必须允许本次动作。 |
任一校验不通过时,Agent 平台应返回 `POLICY_REJECTED` 或 `FORBIDDEN_SCOPE`,不得发起额外批准流程。
### 8.1 字段级约束速查
| 对象/接口 | 必填最小集合 | 禁止内容 |
|---|---|---|
| `orchestration_plan` | `intent_id`、`template_hint`、`objective`、`risk_level`、`budget`、`user_context`、`billing_context`、`agent_runtime`、`metadata.correlation_id`、`agents[]` | 密钥、连接串、真实主机登录密码、NewAPI key 原文。 |
| `agents[]` | `role_template`、`goal` | 让子 Agent 绕过 Manager/Agent 审计的指令。 |
| `runtime_execution` | 任一字段存在时 `profile_id` 必填 | 明文 kubeconfig、SSH key、云访问密钥。 |
| `resource_grants[]` | `grant_id`、`resource_id`、`resource_type`、`user_id`、`binding_scope`、`target_role`、`target_agent_ref`、`permission_scope`、`status` | 明文 `password`、`token`、`private_key`、`access_key`、`credential`、数据库 DSN。 |
| 日志/事件/审计返回 | `request_id` 或 `correlation_id`,以及发生时间 | 未脱敏命令行、环境变量 dump、密钥片段。 |
| NewAPI 重建/部署场景 | `approval_id`、`rollback_command_ref`、健康检查引用、运行环境 `secret_ref` | 真实 VM 密码、PostgreSQL/Redis 连接串、NewAPI 服务令牌。 |
### 8.2 联调验收清单
- 创建部署请求只包含 `secret_ref`/`env://`/`runbook://` 引用,不包含真实凭据。
- `user_id` 与 `binding_scope` 在 user context、resource grant、事件和审计中一致。
- `risk_level=high` 的生产运维任务包含 `approval_id` 和回滚引用。
- 日志、事件、监控、审计接口都能通过 `correlation_id` 串联。
- NewAPI 重建/部署只在实际执行并通过健康检查后标记为已部署;未执行时状态只能是 `planned`、`pending_approval`、`accepted` 或 `running`。
- Manager 本地 `/api/agent/*` 占位端点通过 payload 校验不等于生产 Agent 平台已上线。
---
## 9. Manager 侧当前实现映射
当前代码中可用于对齐/验证 payload 的 Manager 侧端点:
| Manager 路由 | 用途 |
|---|---|
| `POST /api/agent/deployments` | 校验并接受 orchestration_plan。 |
| `GET /api/agent/deployments` | 按 user/binding scope 查询部署。 |
| `GET /api/agent/deployments/:deployment_id` | 查询部署详情。 |
| `POST /api/agent/deployments/:deployment_id/stop` | 停止部署。 |
| `GET /api/agent/deployments/:deployment_id/logs` | 查询脱敏日志占位/联调日志。 |
| `GET /api/agent/deployments/:deployment_id/metrics` | 查询单部署指标占位/联调指标。 |
| `GET /api/agent/deployments/:deployment_id/events` | 查询事件。 |
| `POST /api/agent/sk-snapshots/resolve` | 解析 SK 快照。 |
| `GET /api/agent/deployments/:deployment_id/sk-snapshots` | 查询 SK 快照。 |
| `GET /api/agent/projects/:project_id/dashboard-snapshot` | 资源作用域监控快照;路径名保留旧兼容。 |
| `GET /api/agent/audit-logs` | 审计日志。 |
生产对接时,Manager 应把相同契约的请求发送给 Agent 平台;本地 Manager 端点仅作为最小验证与控制面占位,不代表所有日志/监控平台能力已完整实现。
当前本地 `logs` 与 `metrics` 端点只返回脱敏占位/联调数据,用于验证 Manager ↔ Agent payload、路由和验收流程。生产级实时日志流 `GET /api/agent/deployments/{deployment_id}/logs/stream` 仍属于 Agent 平台能力;Manager 不得把“本地占位通过”误报为“生产日志/监控已上线”。
+219 -1
View File
@@ -120,6 +120,29 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
> 客户端只需 `models`(当前可用 `gpt-5.4`);`modes` 是旧任务模式,新模型已无意义。
> ⚠️ **能力发现 vs 登录用户模型列表**:`/api/heicode/capabilities` 是**免登录**的总目录(渲染模型选择用)。**登录后客户端展示的「我能用哪些模型」必须走 §3.1 `/api/heicode/available-models`**——它按当前用户的分组/订阅服务端收口,且**只有** HM 这一个来源:客户端不得使用本地 preset,也不得从 CodeGW 渠道后台读取模型。
---
## 3.1 登录用户可用模型 🟢(模型列表收口)
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `/api/heicode/available-models` | `UserOrV2DeviceAuth`(会话/JWT 或设备签名) | 当前登录用户**实际可用**的模型列表;服务端按用户可用分组 → 分组启用模型解析 |
```json
{ "success": true, "data": {
"available_models": [
{ "model_id": "gpt-5.4", "display_name": "gpt-5.4", "default": true }
]
}}
```
- 字段仅 `model_id` / `display_name` / `default`(默认模型)。
- **禁止暴露字段**:`channel_id`、`base_url`、`api_key`、供应商类型、价格/倍率等一律不返回。
- **唯一模型来源**:客户端登录后模型列表完全来自此接口,不使用本地 preset / 不读 CodeGW 渠道后台。
- 鉴权要求登录用户上下文(`id>0`);未登录返回 `authentication required`。
---
## 4. Agent 模板列表 🟢
@@ -146,6 +169,66 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
---
## 4.1 启动前预检 / 执行摘要 🟢(preflight)
部署 agent 前,给用户看一份「执行摘要」:用哪些资源、还缺什么、有哪些高危操作、预算上限。用户**只确认摘要**,不必面对完整参数。
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `/api/heicode/preflight?template_id=&binding_ids=1,2,3` | `UserOrV2DeviceAuth` | 返回缺失项 + 可读执行摘要 + `version` |
| POST | `/api/heicode/preflight/confirm` | `UserOrV2DeviceAuth` | 确认摘要 → 记审计 + 返回防篡改 `version`(#41) |
- `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,
"version": "pfv1_3a9c…"
}}
```
- **`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(不阻断,供前端提示)。
- **`version`**:防篡改摘要版本(#41),由**稳定安全面**派生(template + 资源 + 高危 + 必需缺失项);**不含**易变的预算数字,故余额波动不会改版本。
### 4.1.1 确认 + 防篡改版本(#41)
`POST /api/heicode/preflight/confirm` body:`{ "template_id":"architect", "binding_ids":[17] }`
```json
{ "success": true, "data": {
"version": "pfv1_3a9c…", // 把它带到部署请求
"template_id": "architect",
"summary": { …同上执行摘要… },
"confirmed": true
}}
```
- 仅当 `ready=true` 才能确认;否则返回 `POLICY_REJECTED`(先补齐缺失项)。
- 确认会**持久化一条强一致的确认记录**(user+template+version,默认 TTL `HEICODE_PREFLIGHT_CONFIRMATION_TTL_SECONDS`=3600s),并附带写一条审计事件 `preflight.confirmed`。`version` 的派生**已纳入模板安全面**(definition/model/name)——管理员改了同一模板的 definition/model,旧确认即失效。
- **部署校验**:`POST /api/heicode/agents` 可带 `preflight_version`。HM 校验三件事:① 用**当前**资源/模板状态重算版本 == 传入(资源增删/改类型/改凭证、模板变更 → 不匹配即拒);② 当前摘要仍 `ready`(预算/agent_slot 等易变项重查,防 confirm 后余额耗尽仍启动);③ **存在该版本的未过期确认记录**(杜绝直接拿 GET 的 `version` 绕过 confirm)。
- 默认**仅在带了 `preflight_version` 时校验**(向后兼容,不带照常部署);设 `HEICODE_PREFLIGHT_REQUIRED=true` 则**强制**要求先 confirm。
---
## 5. 我的 Agent 🟢(已对接 AM,生产验证)
> **2026-06-04 生产实测**:部署 / 列表 / 详情 / 状态 / 删除均真实可用。
@@ -177,7 +260,12 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
"status": "Pending", // Pending | running | failed | stopped …
"runtime_id": "dep-4bb07dc1e376", // AM 侧运行时 id
"created_at": "2026-06-04T09:04:15Z",
"updated_at": "2026-06-04T09:04:15Z"
"updated_at": "2026-06-04T09:04:15Z",
"security": { // ★ #55 A2A 直连安全元数据
"scheme": "http", // http | https
"security_profile": "none", // none | tls | mtls
"secure": false // = security_profile != none
}
}
],
"total": 1
@@ -198,6 +286,77 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
---
## 5.1 Agent 模型用量 🟢(按部署 Agent 聚合)
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `/api/heicode/agents/{deployment_id}/usage` | `UserOrV2DeviceAuth`,且只能查**自己**的 agent | 按该 agent 的隐藏模型 token(name=`agent:<deployment_id>`)在计费 logs 中聚合用量 |
查询参数(可选):`start` / `end` = unix 秒时间窗(缺省=全窗口)。
```json
{ "success": true, "data": {
"agent_id": "dep_4bb07dc1e376",
"quota": 12345, // 消耗的额度(配额单位)
"prompt_tokens": 8000,
"completion_tokens": 4000,
"call_count": 12,
"quota_per_unit": 500000, // 额度→货币换算分母(quota/quota_per_unit=美元额度)
"budget_remaining": 1234567 // ★ 用户钱包剩余额度(预算剩余;-1=读取失败,不阻断展示)
}}
```
- **空数据语义**:无调用记录时各计数为 `0`(仍返回 `success:true`,不是 404)。
- **`budget_remaining`(#9)**:用户剩余可用额度(同 `quota_per_unit` 口径换算)。Agent 模型调用经隐藏 token 计费到 `user.Quota`,故"本任务预算剩余"= 用户钱包剩余额度。
- **与 billing logs 的关系**:用量来自统一计费 logs(`SumAgentUsage`),按 token name `agent:<deployment_id>` 过滤聚合 —— 即 agent 走 HM `/v1/*` 的真实消耗,与用户钱包/订阅扣费同源。
- **计费归集语义(#30)**:每个部署的 agent,HM 为其 mint 一个**隐藏、不展示在用户 token 列表**的模型 token(`UnlimitedQuota:true`)。`UnlimitedQuota` 的含义是「**不对该 token 自身设单独的剩余额度上限**」——它**不**绕过用户额度:agent 经此 token 调 `/v1/*` 时,HM 仍先校验 `user.Quota`,并在结算时从 `user.Quota`(钱包)或订阅项扣费、写计费 log,完全经过计费表达式。停止/删除 agent 后该 token 被撤销,旧 token 无法再调 `/v1/*`。该 token 对普通用户隐藏,但审计/管理员可追踪。
---
## 5.2 Swarm 运行查询 🟢(#45/#46 · 契约 v1 已冻结)
多 Agent 蜂群运行(`agent_swarm` / HeiCode Swarm)的查询。读类接口数据全部来自 HM 已持久化的运行时回调(Swarm → HM 带签名回调),**无需实时调 Swarm**;契约已冻结为 `runtime-contract v1`/`event-schema v1`(`agent_swarm#14`/`#15`)。
| 方法 | 路径 | 说明 | 状态 |
|---|---|---|---|
| GET | `/api/heicode/swarms` | 列出我的 swarm 运行 | 🟢 本地数据 |
| GET | `/api/heicode/swarms/:id` | 单个运行状态(:id = deployment_id / swarm_id / correlation_id 任一) | 🟢 |
| GET | `/api/heicode/swarms/:id/events?after=&limit=` | 事件增量拉取(`after`=上次返回的 `next_after`,oldest-first) | 🟢 |
| GET | `/api/heicode/swarms/:id/events/stream?after=` | **事件 SSE 实时流**(与 events 同源;进入即回放 `after` 之后历史) | 🟢 |
| GET | `/api/heicode/swarms/:id/artifacts` | 从已存事件派生的产物 | 🟢(派生) |
| POST | `/api/heicode/swarms/:id/stop` | 停止运行(**写**,真实调运行时) | 🟢(开关:`SWARM_RUNTIME_ENABLED=true` + base_url + service token;否则 `POLICY_REJECTED`) |
```json
// GET /api/heicode/swarms/:id
{ "success": true, "data": {
"deployment_id":"dep_…", "swarm_id":"swarm-…", "correlation_id":"…",
"status":"blocked", // 运行时真实状态(契约 §4)
"display_status":"degraded", // 客户端展示态(§4.1 映射:blocked→degraded,余直通)
"goal_summary":"…", // 单行目标(从 plan objective 提取,折叠/截断/脱敏;取不到为空串)
"phase":"…", "runtime_state":"…", "failure_reason":"",
"created_at":"…","updated_at":"…","runtime_last_sync_at":"…" }}
// GET /api/heicode/swarms/:id/events?after=120
{ "success": true, "data": {
"items":[ {"id":121,"sequence":42,"event_type":"task.completed","task_id":"…","result":"ok","occurred_at":"…","payload":{…}} ],
"next_after":121, "count":1 }}
// GET /api/heicode/swarms/:id/artifacts (从 artifact.created 事件派生,扁平)
{ "success": true, "data": {
"items":[ {"event_id":"evt-…","sequence":50,"task_id":"…","uri":"azblob://…","checksum":"sha256:…","created_at":"…"} ],
"total":1 }}
```
- 状态机(契约 §4):`waiting_approval → running →(blocked ⇄ running)→ completed/failed/stopped`;客户端展示用 `display_status`(§4.1:`blocked→degraded`;`preparing`/`verifying` 是运行时 running 子态,HM 未单独存,不臆造)。
- **事件**:`sequence` = agent_swarm event-schema v1 的 **per-swarm 严格递增序号**(每 swarm 从 1、无空洞),客户端用它去重/排序;`id`/`next_after` 是 HM 不透明分页游标(单调,兼容 `sequence` 尚未全量上线)。事件 `payload` **已脱敏**(递归剔除 `secret_ref`/`credential_ref`/`signing_secret_ref`/credentials/大字段 + `RedactText` 兜底)。查询按**当前用户**作用域。
- **新增事件类型**(已注册):`swarm.completed/failed/stopped`、`approval.approved/rejected`、`handoff.created`(event-schema v1)。
- **artifact**:从 `artifact.created` 派生扁平 `{uri,checksum,task_id,size_bytes?,created_at}`(**无 secret_ref**;size 未知则省略,不伪造)。
- **SSE 实时流**(`/events/stream`):`text/event-stream`,帧 `event: message` 的 `data` 即单条事件视图(同 `events` 的 item);命中终态(`swarm.completed/failed/stopped`)发 `event: done`(`{next_after,reason}`)后收流,客户端断开或超时(30min)亦收流。与轮询 `events?after` 等价,二选一;断线后用最后的 `next_after` 重新订阅即可续传。
- **stop**:`SWARM_RUNTIME_ENABLED=true` 且配齐 base_url + service token 时真实调用运行时(`X-Idempotency-Key` 幂等),返回 `{accepted:true,runtime_status,...}`;**终态不在响应里**——由运行时异步回推 `swarm.stopped` 事件,客户端经 `events`/`stream` 收最终态。未启用则 `POLICY_REJECTED`(不伪造受理)。
- `stop`(写):仍 gated,待 stop 真实接入 PR(复用运行时客户端 + `SWARM_RUNTIME_SERVICE_TOKEN`,契约 §3 已冻结)落地。
---
## 6. 直连 Agent(客户端 ↔ agent,A2A 协议)
> 客户端**直接连 agent 子域名**(§5 的 `subdomain`),不经过 HM。agent 是 AM 的 `coding_a2a_agent`,走 **A2A 协议**。
@@ -205,6 +364,8 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
> ⚠️ **连之前先确认 agent 就绪**:新建后 `status=Pending`(还在拉起)。等 `GET /api/heicode/agents/{id}/status` 变 `running`、或 `GET {subdomain}/health` 返 200 再连。**2026-06-04 复测:数秒即 `running`,`/health` 200、`/message/send` 带令牌任务 `completed`,直连已通。**
>
> ⚠️ 当前 AM 侧两点(待加固,不影响调通):① **令牌校验尚未真正生效**——无 `X-Agent-Access-Token` 也被放行;客户端仍应规范地每请求都带,等 AM 开启校验即自动生效。② 子域名目前 `http://` 明文,令牌/`api_key` 会明文传输,等 AM 上 HTTPS。
>
> 🔒 **传输安全门(#55)**:agent 对象回传 `security`(`scheme` http/https、`security_profile` none/tls/mtls、`secure` 布尔)。客户端据此在生产强制 HTTPS(`HEICODE_AGENT_REQUIRE_SECURE=1`):`secure=false`(当前明文)→ 拒绝直连并提示。AM 上线 HTTPS/mTLS listener 后 `security_profile` 自动变 tls,客户端无需改包。HM 保守口径:无法确证 TLS 即标 `none`。
- **同步**:`POST {subdomain}/message/send`
- **流式**:`POST {subdomain}/message/stream`(返回 `text/event-stream`)
@@ -242,6 +403,59 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
---
## 7.1 客户端运行时配置 🟢
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `/api/heicode/config` | 无(公开,非敏感全局配置) | 客户端 runtime config / telemetry 开关;客户端轮询以便会话内即时生效(无需重登) |
```json
{ "success": true, "data": {
"telemetry": {
"enabled": false, // 默认关闭(kill switch)
"endpoint": "/api/heicode/telemetry/events",
"max_batch": 20,
"flush_interval_sec": 30,
"retention_days": 30 // 服务端保留期:超期遥测被清理(#32)
}
}}
```
- 客户端必须以 `telemetry.enabled` 为准:为 `false` 时**停止上送**(摄入端点也会回 410)。
---
## 7.2 客户端错误遥测上送 🟡(默认关闭)
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| POST | `/api/heicode/telemetry/events` | `UserOrV2DeviceAuth` + **V2 设备签名**(需配对设备) | 上送客户端错误遥测;**诊断流量,绝不计费、不进 consume log** |
- **默认关闭**:`HEICODE_TELEMETRY_ENABLED=false` 时返回 **410**(kill switch),客户端应停止上送。
- **鉴权**:需登录用户 + 已配对设备;请求头带 `X-Heicode-Device-Id`。会话-only(无设备)调用被拒(403)。
- **Body = 顶层 JSON 数组**(不是包裹对象),**1–20 条/批**,**≤256KB**。超限 413,非数组 400。
- **每条事件**字段(诊断用,无用户内容):`client_id`(须等于配对设备 id)、`schema_version`、`app_version`、`platform`、`os_version`、`arch`、`locale`、`error_category`、`error_code`、`error_message_hash`、`stack_hash`、`stack_top`(数组)、`context`(对象)、`timestamp`、`session_seq`。
- **服务端脱敏**:`stack_top` / `context` 即使客户端已脱敏,服务端仍二次 redaction(剥离 `sk-`/`Bearer`/URL token/JSON 密钥字段)。
- **context 字段白名单(#32)**:`context` 仅保留 `route` / `retryable` / `phase` / `exit_code` / `duration_ms` / `attempt`;其余键(含 email、完整文件路径、prompt、IP 原文等可识别信息)**一律丢弃**。`stack_top`/`context` 单字段脱敏后截断到 8KiB。
- **重试语义**:4xx(校验失败/超限/kill switch 410)**丢弃不重试**;5xx(持久化失败)可重试。
- **保留期(#32)**:服务端按 `HEICODE_TELEMETRY_RETENTION_DAYS`(默认 30)定期清理超期遥测。
> ⚠️ **上线前置门槛(#32)**:`device_id`/`client_id` 可关联账号,属隐私敏感。生产开启 `HEICODE_TELEMETRY_ENABLED=true` 前必须:隐私文档已如实披露「设备 ID 可关联账号的错误遥测」、产品/法务已确认、kill switch 已验证。隐私文档同步见 heicodeDocs(#34)。
```json
// 请求体(顶层数组,示意一条)
[
{ "client_id":"<paired-device-id>", "app_version":"0.5.0", "platform":"win32",
"error_category":"ui_crash", "error_code":"RENDERER_ERROR",
"error_message_hash":"9f2a7c1b4e8d", "stack_hash":"a1b2c3d4e5f6",
"stack_top":["at MessageList (MessageList.tsx:212:9)"],
"context":{"route":"chat"}, "timestamp":"2026-06-09T07:21:33.123Z", "session_seq":1 }
]
// 成功:{ "success": true, "accepted": 1 }
```
---
## 8. 响应 envelope 与错误码
**成功**:`{ "success": true, "data": {…} }`
@@ -287,11 +501,15 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
| 接口 | 鉴权 | 状态 |
|---|---|---|
| `GET /api/heicode/capabilities` | 无(公开) | 🟢 |
| `GET /api/heicode/available-models` | 会话/设备 | 🟢(登录用户模型列表收口,唯一来源) |
| `GET /api/heicode/config` | 无(公开) | 🟢(runtime config / telemetry 开关) |
| `GET /api/user/self`、`/self/models` | **UserAuth(会话/JWT)** | 🟢 |
| `GET /api/heicode/agent-templates` | 会话/设备 | 🟢(生产已验证 19 中文模板) |
| `GET /api/heicode/agents` `/{id}` `/{id}/status` | 会话/设备 | 🟢 |
| `POST /api/heicode/agents` | 会话/设备 | 🟢(调 AM,AM 接口 🔴) |
| `POST /api/heicode/agents/{id}/stop`、`DELETE /{id}` | 会话/设备 | 🟢(调 AM 🔴) |
| `GET /api/heicode/agents/{id}/usage` | 会话/设备(仅自己的 agent) | 🟢(按 `agent:<id>` token 聚合计费 logs) |
| `POST /api/heicode/telemetry/events` | 会话/设备 + V2 设备签名 | 🟡 默认关闭(410 kill switch);不计费 |
| 模型 `/v1/*` | 同模型调用 | 🟢 |
| `POST /api/heicode/agent-access/verify` | 公开(令牌即凭据) | 🟢(可选兜底;接入用 ① 本地比对,默认不用此端点) |
| 直连 agent SSE(subdomain + X-Agent-Access-Token) | agent 本地比对令牌 | 🟢 直连已通 / ⚠️ 令牌校验待 AM 开启 |
@@ -1,151 +0,0 @@
# Heicode Manager 蜂群 Runtime 联调配置模板
更新时间:2026-05-27
适用范围:Heicode Manager 调用 Agent Manager / HeiCode-Swarm Runtime。
## 一、标准 Agent Manager Runtime 配置
当 Runtime 提供正式 `POST /api/swarms` 时,Manager 建议配置:
```bash
AGENT_RUNTIME_ENABLED=true
AGENT_RUNTIME_ASYNC=false
AGENT_RUNTIME_BASE_URL=http://<runtime-host>
AGENT_RUNTIME_SERVICE_TOKEN=<service-token>
AGENT_RUNTIME_HEALTH_PATH=/api/agent/health
AGENT_RUNTIME_CREATE_PATH=/api/swarms
AGENT_RUNTIME_STOP_PATH=/api/swarms/{swarm_id}/stop
AGENT_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
AGENT_RUNTIME_TIMEOUT_SECONDS=10
HEICODE_PUBLIC_BASE_URL=https://code.xinghanlab.com
AGENT_RUNTIME_CALLBACK_URL=https://code.xinghanlab.com/api/agent/callbacks/swarm-events
AGENT_CALLBACK_TOKEN=<callback-token>
```
说明:
1. `AGENT_RUNTIME_SERVICE_TOKEN` 是 Manager 调 Runtime 的 Bearer token。
2. `AGENT_CALLBACK_TOKEN` 是 Runtime 回调 Manager 时放在 `X-Agent-Service-Token` 的 token。
3. 两个 token 可以不同,不要写进前端或日志。
4. 如果 Runtime 返回 `swarm_id`,Manager 会保存到 `runtime_swarm_id`。
5. 如果 Runtime 返回 `deployment_id`,Manager 会保存到 `runtime_deployment_id`。
## 二、当前 HeiCode-Swarm Orchestrator 兼容配置
如果短期只用现有 Orchestrator 的 `/health` 和 `/tasks` 先做连通性测试,可以临时配置:
```bash
AGENT_RUNTIME_ENABLED=true
AGENT_RUNTIME_ASYNC=false
AGENT_RUNTIME_BASE_URL=http://52.139.240.116:8000
AGENT_RUNTIME_SERVICE_TOKEN=<optional-if-supported>
AGENT_RUNTIME_HEALTH_PATH=/health
AGENT_RUNTIME_CREATE_PATH=/tasks
AGENT_RUNTIME_STOP_PATH=/tasks/{runtime_deployment_id}/stop
AGENT_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
AGENT_RUNTIME_TIMEOUT_SECONDS=10
HEICODE_PUBLIC_BASE_URL=https://code.xinghanlab.com
AGENT_RUNTIME_CALLBACK_URL=https://code.xinghanlab.com/api/agent/callbacks/swarm-events
AGENT_CALLBACK_TOKEN=<callback-token>
```
注意:
1. `/tasks` 不是最终生产契约,只能做临时兼容测试。
2. 当前 `/tasks` 字段不足以完整表达 `resource_grants`、`secret_ref`、`approval_policy`、`budget`。
3. 若 Orchestrator 不支持 stop 和 approval decision,Manager 会记录回传失败审计,但用户审批记录仍会保存。
## 三、Runtime 回调 Manager 的请求头
推荐使用 service token:
```http
POST /api/agent/callbacks/swarm-events
X-Agent-Service-Token: <callback-token>
X-Agent-Event-Id: <event-id>
X-Correlation-ID: <correlation-id>
Content-Type: application/json
```
也支持 HMAC:
```http
X-Agent-Event-Id: <event-id>
X-Agent-Timestamp: <unix-ms>
X-Agent-Signature: sha256=<hex>
X-Correlation-ID: <correlation-id>
Content-Type: application/json
```
签名内容:
```text
timestamp + "." + event_id + "." + raw_body
```
## 四、最小联调命令
健康检查:
```bash
curl -sS "$AGENT_RUNTIME_BASE_URL$AGENT_RUNTIME_HEALTH_PATH"
```
Manager 本地默认模拟链路:
```bash
curl -sS -X POST "https://code.xinghanlab.com/api/agent/user/deployments/<manager-deployment-id>/simulate-events" \
-H "Authorization: Bearer <manager-user-token>" \
-H "Content-Type: application/json" \
-d '{}'
```
该命令用于 Manager 自测,不代表真实 Runtime 已执行。默认模拟会落 callback、artifact、approval、timeline 记录,并用于检查页面展示和 `secret_ref` 脱敏。
模拟 Runtime 回调 Manager:
```bash
curl -sS -X POST "https://code.xinghanlab.com/api/agent/callbacks/swarm-events" \
-H "Content-Type: application/json" \
-H "X-Agent-Service-Token: $AGENT_CALLBACK_TOKEN" \
-H "X-Agent-Event-Id: evt-smoke-001" \
-H "X-Correlation-ID: corr-smoke-001" \
-d '{
"event_id": "evt-smoke-001",
"event_type": "timeline.updated",
"deployment_id": "<manager-deployment-id>",
"swarm_id": "<runtime-swarm-id>",
"occurred_at": "2026-05-27T10:00:00Z",
"source": "heicode-swarm-runtime",
"payload": {
"title": "Runtime callback received",
"summary": "Manager callback pipeline is reachable",
"severity": "info"
}
}'
```
成功响应:
```json
{
"success": true,
"data": {
"event_id": "evt-smoke-001",
"inserted": true,
"deduplicated": false,
"deployment_id": "<manager-deployment-id>"
}
}
```
## 五、上线前必须确认
| 项 | 标准 |
|---|---|
| create | Manager 能拿到 `runtime_deployment_id` 和 `runtime_swarm_id` |
| callback | Runtime 至少回调 `deployment.status_changed`、`task.completed`、`artifact.created` |
| approval | Runtime 发 `approval.requested`,Manager 用户审批后 Runtime 收到 decision |
| artifact | Manager artifacts 页面能看到 artifact 类型、标题、摘要和 URI |
| timeline | Manager timeline 能区分 `manager`、`runtime`、`simulated` 来源 |
| secret | 请求、响应、日志、artifact metadata 中无长期明文密钥 |
@@ -0,0 +1,64 @@
# Heicode 蜂群(Swarm)—— 现状裁定与后续跟踪入口(HM 侧)
> 起草:2026-06-05 · 更新:2026-06-10(同步 `agent_swarm` 当前状态)· 状态:跟踪占位(HM 侧不实现,归口 AM / Swarm)
>
> 本文是 PR #15「文档大同步」删除全部旧 sub/蜂群文档后留下的**追踪入口**,回答三件事:① 旧文档为什么作废、② 蜂群能力现在归谁、③ 未来对接/待定项在哪里跟踪。删除旧文档≠放弃蜂群能力,**上下文迁移到本文**。
>
> **2026-06-10 勘误**:蜂群仓库名是 **`agent_swarm`**(GitHub `xmindlab-heicode/agent_swarm`;产品名 **HeiCode Swarm**),不是 `HeiCode-Swarm`。该仓已从「仅 `/tasks`」演进为完整的 Master-Agent 编排运行时并起草了正式契约 `runtime-contract.md`——本文下文已据实更新。
---
## 1. 裁定(当前结论)
- **HM(Heicode Manager)当前不实现 swarm runtime。** HM 的职责边界是:模型网关(`/v1/*`)+ 资源/权限/计费/审计 + **模板 Agent 部署编排**(经 AM 启动常驻 agent、客户端直连)。多 agent 蜂群编排**不在 HM 端**。
- **旧的「HM 内部 sub/蜂群任务编排」模型已作废。** 那套(sub 任务、display_status、HM 侧蜂群 runtime 对接草案)随产品转向「模板 Agent + 客户端直连」一并下线,相关代码删除清单见 [`heicode-hm-legacy-teardown.md`](./heicode-hm-legacy-teardown.md),当前模型见 [`heicode-hm-template-agent-model.md`](./heicode-hm-template-agent-model.md)。
- **新版蜂群能力在 `agent_swarm`(HeiCode Swarm)仓,不在本仓。** 当前模型:主控 Agent(Master Agent,`orchestrator/master_agent.py`)把需求**分解**为子任务 → **派发**给不同领域的专家 Agent **并行执行** → 重叠领域**协作/移交** → 主控**评审/重做**循环(受 `MAX_REVIEW_CYCLES` 约束)→ **汇总交付**;Orchestrator(FastAPI) + Redis 权威状态 + WebSocket Agent 协议 + Prometheus 指标。旧文档描述的「HM 主导编排蜂群 / 仅 `/tasks`」已作废。HM 侧最多提供资源/计费/鉴权支撑面,runtime 与编排由 Swarm 承载。
- **契约已冻结为 v1(2026-06-10)。** Swarm 侧已冻结 `runtime-contract v1`(`agent_swarm#14`:stop = `POST /api/agent/swarm/deployments/{deployment_id}/stop` + `Bearer SWARM_RUNTIME_SERVICE_TOKEN` + `X-Idempotency-Key`,受理后异步回推 `swarm.stopped`;`deployment_id↔swarm_id↔manager_deployment_id` 三映射;状态 §4.1 `blocked→degraded`)与 `event-schema v1`(`agent_swarm#15`:per-swarm 严格递增 `sequence`;新增 6 类事件;`secret_ref`/`credential_ref`/`signing_secret_ref` 按设计以 `azkv://` 引用透传)。
- **HM 侧已据冻结契约落地读侧 + stop + SSE(不再 deferred 的部分):**
- **只读查询(#45 / PR #59)🟢**:list/status/`events?after`/artifacts 全部基于 HM 已持久化的回调事件(`agent_callback.go` → `AgentCallbackEvent`),按 `user_id` 收口、payload 递归脱敏(剔除 secret_ref/凭据/大字段 + `RedactText` 兜底),**无需调用 Swarm 运行时**。`controller/agent_swarm_query.go`。
- **stop(#45)🟢(受开关约束)**:真实调用运行时冻结路径,复用 `agent_runtime_client` 的配置/URL/信封解析;`SWARM_RUNTIME_ENABLED=true` 且配齐 `SWARM_RUNTIME_BASE_URL` + `SWARM_RUNTIME_SERVICE_TOKEN` 时才发起,否则 `POLICY_REJECTED`,**绝不伪造 accepted**。终态不抢写,由 `swarm.stopped` 回调写回。
- **事件 SSE 实时流(#46 读侧)🟢**:`GET /api/heicode/swarms/:id/events/stream?after=`,与 `events?after` 同源(HM 持久化事件),命中终态(`swarm.completed/failed/stopped`)/客户端断开/超时结束,**不依赖运行时**。
- **仍 deferred / 阻塞的部分:** ① **per-user 计费令牌注入(#60)**——swarm create 目前未像模板 Agent 那样现签隐藏 `sk-` 注入运行时(`OPENAI_API_KEY`+`OPENAI_API_BASE=HM/v1`),是 HM 唯一计费缺口,**阻塞于 Swarm 对 `agent_swarm#16` 注入口径 A/B 的确认**;② 事件 **append 写入 / 主动拉取**仍走回调被动落库,未做 HM→Swarm 主动拉取(无对应需求)。
---
## 2. 能力归属与后续跟踪入口
| 项 | 归属仓 / 负责人 | 说明 |
|---|---|---|
| Swarm runtime / 多 agent 编排 | **`agent_swarm`**(产品名 HeiCode Swarm,@Songhaoz666) | 执行面、Master-Agent 编排、回调、Swarm Runtime |
| Manager ↔ Swarm 契约 | Swarm 侧契约**已冻结为 v1**:`runtime-contract v1`(`agent_swarm#14`)+ `event-schema v1`(`agent_swarm#15`),见 `agent_swarm/docs/integration/`;HM 侧据此对接,本文为 HM 侧锚点 | Swarm 已实现生命周期接口 create/status/tasks/logs/events/metrics/workflow/diagnostics/stop/approvals(均带 `deployment_id`,三组路径别名 `/api/swarms`、`/api/agent/swarm/deployments`、`/api/agnet/deployments`);HM 接入跟踪在 #45/#46/#60 与 PR #59 |
| Agent 运行时(单 agent,已落地) | **`agent_management`(AM)**(@azgy) | 模板 Agent 启动/状态/停止/删除,契约见 [`heicode-am-contract.md`](./heicode-am-contract.md) |
> **HM 侧对接状态(2026-06-10)**:契约已按 `runtime-contract v1`/`event-schema v1` 冻结(`agent_swarm#14`/`#15`)。HM 已落地 #45(只读查询 + stop,PR #59 + 本批)与 #46 读侧(events SSE);剩余阻塞仅 #60(per-user 计费令牌注入,待 `agent_swarm#16` A/B 确认)。本文仍是「蜂群在 HM 侧」的唯一锚点——不恢复旧文档。
---
## 3. 已删除文档的迁移映射(PR #15)
> 对照 Fasthei 复审要求:每份删除文档标明是「作废 / 迁移 / deferred」,以及上下文去向。
### 蜂群 / swarm 相关(7 份,上下文迁移至本文 + Swarm 仓)
| 删除的文档 | 处置 | 去向 |
|---|---|---|
| `docs/heicode-manager-sub-swarm-progress-checklist.md` | **deferred** | 旧 HM 内部 sub/蜂群编排进度;未完成事项随模型作废,新蜂群进度归 Swarm 仓跟踪 |
| `docs/heicode-manager-swarm-gap-analysis.md` | **作废** | 针对废弃的「HM 主导蜂群」模型的 gap 分析,前提不再成立 |
| `docs/integration/agent-manager-swarm-runtime-change-request.md` | **deferred → AM/Swarm** | 旧 HM→AM 蜂群 runtime 变更请求;如需重提,由 Swarm 侧按新契约发起 |
| `docs/integration/AgentManager蜂群Runtime接口实现要求.md` | **deferred → AM/Swarm** | 蜂群 runtime 接口要求归 Swarm 侧实现与跟踪 |
| `docs/integration/AgentManager蜂群Runtime联调待确认与补充要求.md` | **deferred → AM/Swarm** | 联调待确认项随新契约在 Swarm 侧重列 |
| `docs/integration/heicode-manager-swarm-runtime-env-template.md` | **作废** | 旧蜂群 runtime env 模板,对应废弃的 HM 编排路径 |
| `docs/integration/蜂群模式-AgentManager对接任务清单.md` | **deferred → AM/Swarm** | 对接任务清单随新契约在 Swarm 侧重建 |
### 其它旧文档(4 份,被新主线文档取代)
| 删除的文档 | 处置 | 取代者 |
|---|---|---|
| `Heicode-Manager-项目说明与踩坑交接.md` | **作废** | 被 HM-only 化的 `README.md` / `CLAUDE.md` / `AGENTS.md` 取代 |
| `docs/Heicode-Manager-agent统一改造落地计划.md` | **作废** | 被 `integration/heicode-hm-template-agent-model.md` 取代 |
| `docs/heicode-manager-standalone-execution-plan.md` | **作废** | 被 `plan.md` 取代 |
| `docs/integration/agent-platform-request-contract.md` | **作废** | 旧出站契约,被 `integration/heicode-am-contract.md` 取代 |
---
## 4. 给后续开发者的一句话
要找「蜂群在 HM 侧怎么对接」——**当前答案是「HM 不实现 swarm runtime;但契约已冻结(`runtime-contract v1`/`event-schema v1`),HM 已据冻结版落地只读查询 + stop + 事件 SSE(#45/#46/PR #59),代码在 `controller/agent_swarm_query.go`;唯一剩余计费缺口是 per-user `sk-` 注入(#60),阻塞于 `agent_swarm#16`」**;旧设计(HM 主导编排 / 仅 `/tasks` / `HeiCode-Swarm` 仓名)已废,别从 git 历史里捞旧文档当依据,按本文与 `agent_swarm` 仓的最新结论走。
@@ -0,0 +1,53 @@
# 客户端错误遥测 — 生产开启前置 Checklist(定稿)
> 工单:#44(拆分自 #32 EPIC)· 状态:**工程项已就绪;开启前置 = 隐私披露文档已发布 + 评审通过**
> 适用:把 `HEICODE_TELEMETRY_ENABLED` 从默认 `false` 翻到 `true` 之前的强制检查清单。
## 0. 一句话
遥测**默认关闭**(摄入端点回 410 kill switch)。在本清单**全部勾选**前,生产 **不得** 开启;尤其是 §3 的隐私披露是硬前置——工程已把代码侧防线做齐,但「设备 ID 可关联账号」属隐私敏感,必须先在隐私文档如实披露并发布。
## 1. 代码侧控制(已实现,可现场核验)
| 控制 | 实现 | 核验方式 |
|---|---|---|
| 默认关闭 + kill switch | `HEICODE_TELEMETRY_ENABLED` 默认 `false`,关时摄入端点返回 **410** | `GET /api/heicode/config` → `telemetry.enabled=false`;`POST /api/heicode/telemetry/events` → 410 |
| 不计费、不进 consume log | 独立 `telemetry_events` 表,摄入不写消费日志/不动 quota | 代码 `controller/heicode_telemetry.go`;开启后抽查无 consume log |
| 仅设备配对可上送 | 需 V2 设备签名 + `X-Heicode-Device-Id`;会话-only 拒绝(403) | 无设备头请求 → 403 |
| 批量/尺寸限制 | 顶层数组 1–20 条、≤256KB | 超限 → 413 |
| context 字段白名单(#42) | 仅 `route/retryable/phase/exit_code/duration_ms/attempt`,其余键丢弃 | `TestFilterTelemetryContext_Whitelist` |
| 单字段尺寸上限 | `stack_top`/`context` 脱敏后截断 8KiB | `TestCapTelemetryField` |
| 服务端二次脱敏 | `stack_top`/`context` 剥离 sk-/Bearer/URL token/JSON 密钥字段 | `TestTelemetryToModel_RedactsSecrets` |
| 数据保留期(#43) | `HEICODE_TELEMETRY_RETENTION_DAYS`(默认 30)+ master-only 每日清理 | `GET /api/heicode/config` → `retention_days`;`TestDeleteTelemetryEventsBefore` |
**禁止采集(白名单已强制)**:prompt、代码正文、token、邮箱、完整文件路径、用户名路径、IP 原文等可识别信息——非白名单 `context` 键一律丢弃 + 服务端脱敏兜底。
## 2. 生产配置确认(开启时设置 + 复核)
- [ ] `HEICODE_TELEMETRY_ENABLED=true`(仅在 §3 全部完成后)
- [ ] `HEICODE_TELEMETRY_RETENTION_DAYS` 已确认(建议 ≤90;默认 30)
- [ ] `HEICODE_TELEMETRY_RETENTION_INTERVAL_HOURS` 已确认(默认 24)
- [ ] 仅 master 节点跑清理任务(`IsMasterNode`,已在 `main.go` 内置)
- [ ] `GET /api/heicode/config` 返回的 `telemetry` 块与上述配置一致
## 3. 隐私披露前置(硬性,开启的真正闸门)
> 这一节是**文档 + 评审**条件,不是代码项;由对应文档 PR 经评审通过即满足。
- [ ] **heicodeDocs 隐私文档如实披露**「客户端错误遥测;**设备 ID 可关联账号、非匿名**;采集范围(崩溃类别/错误码哈希/脱敏调用栈/运行环境);保留期;不采集 prompt/代码/token/邮箱/完整路径/IP 原文」(跟踪:#34,草案已在 #34 给出)
- [ ] 该隐私披露 PR **已评审通过并合并**(在 #44 / #34 留 PR 链接为开启决定留痕)
- [ ] 隐私文档**已发布上线**(用户可见),且与实际采集行为一致(`法律声明.md`「隐私披露必须与真实采集行为一致」)
## 4. 上线 / 回滚验证(开启当次执行)
- [ ] 开启前:`POST /api/heicode/telemetry/events` 返回 **410**(确认 kill switch 基线)
- [ ] 开启后:客户端真实上送一批 → `200 {accepted:n}`,落 `telemetry_events` 表
- [ ] 抽查入库行:`stack_top`/`context` 已脱敏;`context` 仅白名单键;无 prompt/邮箱/路径/IP
- [ ] 确认**无** consume log / quota 变化(遥测不计费)
- [ ] **回滚演练**:把 `HEICODE_TELEMETRY_ENABLED` 改回 `false` → 端点回 410、客户端停送(kill switch 可用)
- [ ] 保留期清理任务在日志中可见(`secret/telemetry retention task started`)
## 5. 结论门
**只有 §1 已核验 + §2 已确认 + §3 隐私披露已发布 + §4 验证通过,才允许在生产保持 `HEICODE_TELEMETRY_ENABLED=true`。**
任一项不满足 → 维持默认关闭(410)。本清单作为 #44 的「checklist 定稿」交付;§3 的隐私披露文档(#34)是开启的前置条件。
@@ -1,441 +0,0 @@
# Agent Manager 蜂群模式对接任务清单
更新时间:2026-05-28
发给:Agent Manager / HeiCode-Swarm 负责人
来源:Heicode Manager 当前代码、生产 1.4.9 验证结果、蜂群资料包和 Heicode 产品资料包。
## 1. 结论
Heicode Manager 端已经具备蜂群模式所需的控制面能力:创建 deployment、桥接 Runtime、保存 `runtime_deployment_id` / `swarm_id`、接收 callback、落 artifact、落 approval、聚合 timeline、审批结果回传 Runtime、页面展示 task flow。
Agent Manager / Swarm Runtime 当前还需要补齐或确认的是:真实创建 Swarm Run、生成任务图、Agent claim/heartbeat/handoff/retry/blocked 状态机、artifact 真实产出、审批暂停和恢复、日志指标、用量回传。
## 2. 当前 Manager 已完成
| 能力 | 状态 | 说明 |
|---|---|---|
| Runtime 创建桥接 | 已完成 | Manager 可通过 `AGENT_RUNTIME_BASE_URL` + `AGENT_RUNTIME_CREATE_PATH` 调 Agent Manager |
| Runtime 停止桥接 | 已完成 | 默认 `POST /api/agent/deployments/{deployment_id}/stop` |
| `/api/swarms` adapter | 已完成 | Manager 用户态入口已存在,后续可切真实 Runtime |
| callback 接收 | 已完成 | `POST /api/agent/callbacks/swarm-events` |
| callback 鉴权 | 已完成 | 支持 service token 或 HMAC |
| callback 幂等 | 已完成 | 按 `event_id` / `idempotency_key` 去重 |
| task flow 校验 | 已完成 | `task.*` / `handoff.*` 缺关键字段会返回 `CALLBACK_SCHEMA_INVALID` |
| artifact 落库 | 已完成 | `artifact.created` 可落库并在 deployment 详情展示 |
| approval 落库 | 已完成 | `approval.requested` 可转 Manager 审批记录 |
| approval decision 回传 | 已完成 | 用户 approve/reject 后 Manager 可回传 Runtime |
| 页面验证 | 已完成 | 生产 1.4.9 已验证 `Sub task flow` 展示 task/blocked/retry/handoff |
## 3. Agent Manager 需要实现或确认的 P0
| 任务 | 必需 | 原因 | 验收 |
|---|---:|---|---|
| 提供真实创建 Swarm Run 接口 | 是 | Manager 只能发起,不能替 Runtime 生成真实任务图 | `POST /api/swarms` 或配置等价接口返回真实 `deployment_id` / `swarm_id` |
| 返回稳定 Runtime 标识 | 是 | Manager 要保存 `deployment_id <-> runtime_deployment_id/swarm_id` 映射 | 创建响应包含 `deployment_id` 或 `id`,建议同时包含 `swarm_id` |
| 生成任务图 | 是 | 蜂群模式核心是 task graph,不是单个占位 deployment | Runtime 内部能创建任务节点,并回调 `task.created` |
| Agent claim / heartbeat | 是 | Manager 页面和客户端需要知道哪个 Agent 在做哪个任务 | 回调 `task.claimed`、`task.heartbeat` |
| blocked / retry / failed 状态机 | 是 | 失败恢复和阻塞是蜂群运行核心 | 回调 `task.blocked`、`task.retried`、`task.failed`,字段符合 schema |
| handoff 状态机 | 是 | 蜂群 Agent 间交接需要可审计 | 回调 `handoff.requested`、`handoff.completed` |
| artifact 真实产出 | 是 | Manager 只保存摘要和引用,真实产物来自 Runtime | 回调 `artifact.created`,带 `uri`、`summary`、`artifact_type` |
| 高危审批暂停/恢复 | 是 | 高危操作需要客户端审批后 Runtime 才能继续 | Runtime 回调 `approval.requested`,并接收 Manager decision |
| 日志/指标 | 是 | 线上排障和验收需要真实运行数据 | 提供 logs/metrics 查询或通过 callback 摘要回传 |
| 不记录明文长期密钥 | 是 | 安全红线 | Runtime 只消费 `secret_ref` / `credential_ref`,日志和 artifact metadata 不出现明文密钥 |
## 4. Runtime 创建接口
生产目标:
```http
POST /api/swarms
Authorization: Bearer <service_token>
X-User-ID: <manager_user_id>
X-Binding-Scope: <binding_scope>
X-Correlation-ID: <correlation_id>
X-Idempotency-Key: manager-<manager_deployment_id>
Content-Type: application/json
```
Manager 当前可通过环境变量切换:
```text
AGENT_RUNTIME_BASE_URL=http://20.212.121.126
AGENT_RUNTIME_CREATE_PATH=/api/swarms
AGENT_RUNTIME_HEALTH_PATH=/api/agent/health
AGENT_RUNTIME_STOP_PATH=/api/agent/deployments/{deployment_id}/stop
AGENT_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
```
请求体核心形状:
```json
{
"orchestration_plan": {
"intent_id": "task_xxx",
"objective": "完成本轮用户目标",
"sub_mode": "agile",
"risk_level": "medium",
"budget": {
"max_tokens": 120000,
"max_cost_usd": 8,
"max_duration_sec": 3600
},
"user_context": {
"user_id": "22",
"channel_id": "heicode"
},
"billing_context": {
"provider": "newapi",
"default_model_id": "model_xxx",
"allowed_model_ids": ["model_xxx"],
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key"
},
"agile_context": {
"iteration": "2026-05-28",
"stage": "development",
"checkpoint": "draft_created",
"next_action": "continue",
"requires_user_approval": false
},
"agents": []
},
"agents": [
{
"role": "backend",
"resource_grants": []
}
],
"resource_grants": [
{
"grant_id": "grant-task-backend",
"resource_id": "repo-main",
"resource_type": "git",
"permission_scope": ["repo:read", "repo:write:feature-branches"],
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
"ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
"target_role": "backend"
}
],
"callback": {
"url": "https://code.xinghanlab.com/api/agent/callbacks/swarm-events",
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agent-callback-signing-key",
"subscribed_events": [
"deployment.status_changed",
"task.created",
"task.claimed",
"task.heartbeat",
"task.blocked",
"task.retried",
"task.failed",
"task.completed",
"handoff.requested",
"handoff.completed",
"approval.requested",
"artifact.created",
"timeline.updated"
]
},
"sub_mode": "agile",
"metadata": {
"manager_deployment_id": "dep_xxx",
"heicode_deployment_id": "dep_xxx",
"heicode_runtime_bridge": true,
"correlation_id": "corr_xxx"
}
}
```
响应必须至少满足一种:
```json
{
"success": true,
"data": {
"deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"status": "running"
}
}
```
或:
```json
{
"deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"status": "running"
}
```
Manager 解析规则:
| Runtime 字段 | Manager 保存到 |
|---|---|
| `data.deployment_id` / `deployment_id` / `id` | `runtime_deployment_id` |
| `data.swarm_id` / `swarm_id` / `runtime_swarm_id` | `runtime_swarm_id` |
| `data.status` / `status` | `runtime_state` |
## 5. Runtime 回调 Manager
统一入口:
```http
POST https://code.xinghanlab.com/api/agent/callbacks/swarm-events
X-Agent-Service-Token: <callback_token>
X-Agent-Event-Id: <event_id>
X-Correlation-ID: <correlation_id>
Content-Type: application/json
```
也支持 HMAC:
```http
X-Agent-Timestamp: <unix_ms>
X-Agent-Signature: sha256=<hex>
```
签名内容:
```text
timestamp + "." + event_id + "." + raw_body
```
Agent Manager / 蜂群侧可以先拉取 Manager 当前接受的事件 schema:
```http
GET https://code.xinghanlab.com/api/agent/callbacks/swarm-events/schema
```
该接口不返回任何 token 或密钥,只返回事件类型、分类和必填字段,用于联调前核对 `task.*`、`handoff.*`、`artifact.created`、`approval.requested` 等 callback payload 是否符合 Manager 接收规则。
统一 envelope:
```json
{
"event_id": "evt_xxx",
"idempotency_key": "evt_xxx",
"event_type": "task.blocked",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"agent_instance_id": "agent-backend-1",
"task_id": "task-backend-1",
"occurred_at": "2026-05-28T10:00:00Z",
"correlation_id": "corr_xxx",
"source": "heicode-swarm-runtime",
"payload": {}
}
```
## 6. task / handoff 事件 schema
Manager 已上线校验。以下字段缺失会拒绝,返回 `CALLBACK_SCHEMA_INVALID`。
| event_type | 必填字段 |
|---|---|
| `task.created` | `task_id`, `payload.title` |
| `task.claimed` | `task_id`, `payload.agent_role` |
| `task.running` | `task_id`, `payload.agent_role` |
| `task.heartbeat` | `task_id`, `payload.agent_role` |
| `task.blocked` | `task_id`, `payload.reason` |
| `task.retried` | `task_id`, `payload.attempt` |
| `task.released` | `task_id`, `payload.agent_role` |
| `task.failed` | `task_id`, `payload.reason` |
| `task.completed` | `task_id` |
| `handoff.requested` | `task_id`, `payload.from_role`, `payload.to_role` |
| `handoff.completed` | `task_id`, `payload.from_role`, `payload.to_role` |
| `artifact.created` | `artifact.artifact_id` 或 `payload.artifact_id` |
示例:
```json
{
"event_id": "evt_task_blocked_1",
"event_type": "task.blocked",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"agent_instance_id": "agent-backend-1",
"task_id": "task-backend-1",
"source": "heicode-swarm-runtime",
"payload": {
"task_id": "task-backend-1",
"agent_role": "backend",
"reason": "等待前端接口字段确认",
"severity": "warning",
"next_action": "handoff_to_frontend"
}
}
```
```json
{
"event_id": "evt_handoff_1",
"event_type": "handoff.requested",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"task_id": "task-backend-1",
"source": "heicode-swarm-runtime",
"payload": {
"task_id": "task-backend-1",
"from_role": "backend",
"to_role": "frontend",
"summary": "后端接口已完成,交给前端接页面"
}
}
```
## 7. artifact 回调
Runtime 不要把大文件正文塞进 callback。只回写摘要和可访问引用。
```json
{
"event_id": "evt_artifact_1",
"event_type": "artifact.created",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"task_id": "task-backend-1",
"source": "heicode-swarm-runtime",
"artifact": {
"artifact_id": "art_backend_patch_1",
"artifact_type": "code_patch",
"title": "Backend patch",
"summary": "完成工单列表和状态流转接口",
"uri": "git://repo#feature/task-backend-1",
"checksum": "sha256:abc123",
"metadata": {
"redacted": true,
"agent_role": "backend"
}
}
}
```
## 8. approval 回调和 decision 接口
Runtime 在高危动作前必须暂停,并回调 Manager:
```json
{
"event_id": "evt_approval_1",
"event_type": "approval.requested",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"agent_instance_id": "agent-backend-1",
"task_id": "task-backend-1",
"source": "heicode-swarm-runtime",
"payload": {
"approval_id": "appr_runtime_1",
"operation": "git.write",
"resource_id": "repo-main",
"resource_type": "git",
"resource_scope": "feature/*",
"target_role": "backend",
"risk_level": "high",
"requires_credential": true,
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
"ttl_seconds": 900,
"reason": "需要写入功能分支"
}
}
```
Manager 用户同意或拒绝后会调用 Runtime:
```http
POST /api/swarms/{swarm_id}/approvals/{approval_id}
Authorization: Bearer <service_token>
X-Correlation-ID: <correlation_id>
X-Idempotency-Key: approval-decision-<approval_id>-<approved|rejected>
Content-Type: application/json
```
请求:
```json
{
"approval_id": "appr_runtime_1",
"decision": "approved",
"manager_deployment_id": "dep_xxx",
"runtime_deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"operation": "git.write",
"resource_id": "repo-main",
"resource_type": "git",
"resource_scope": "feature/*",
"target_role": "backend",
"risk_level": "high",
"requires_credential": true,
"user_id": "22",
"credential_ref": "lease://agent/lease_xxx",
"lease_id": "lease_xxx",
"lease_expires_at": 1779850900000
}
```
注意:Manager 不会把长期 `secret_ref` 发回 decision,只会在同意时发短期 `credential_ref`。
## 9. 停止接口
默认:
```http
POST /api/agent/deployments/{runtime_deployment_id}/stop
Authorization: Bearer <service_token>
Content-Type: application/json
```
请求:
```json
{
"reason": "Heicode Manager requested stop",
"manager_deployment_id": "dep_xxx"
}
```
响应:
```json
{
"success": true,
"data": {
"deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"status": "stopped"
}
}
```
## 10. 验收用例
| 用例 | 操作 | 期望 |
|---|---|---|
| health | `GET /api/agent/health` | 200,`status=healthy` |
| create | Manager 创建 deployment | Runtime 返回 `deployment_id` 和 `swarm_id` |
| task flow | Runtime 回调 `task.created/claimed/heartbeat/blocked/retried/completed` | Manager timeline 和页面 Sub task flow 可见 |
| handoff | Runtime 回调 `handoff.requested/completed` | Manager 页面可见 from/to role |
| schema 拒绝 | 发送缺 `to_role` 的 `handoff.requested` | Manager 返回 `CALLBACK_SCHEMA_INVALID` |
| artifact | Runtime 回调 `artifact.created` | Manager artifacts 列表出现产物 |
| approval | Runtime 回调 `approval.requested` | Manager pending approval 出现 |
| decision | 用户 approve/reject | Runtime decision 接口收到 `approved/rejected` |
| stop | Manager stop deployment | Runtime 状态变为 stopped |
| 安全 | 搜索日志和回调 payload | 不出现 password/token/private_key/access_key/connection_string 明文 |
## 11. Agent Manager 不需要做的事
| 不需要做 | 原因 |
|---|---|
| 实现桌面客户端 V2 body 加密 | 这是客户端到 Manager 的协议,Manager 已支持 |
| 直接让普通用户访问 Azure Key Vault | 密钥入口由 Manager 控制,Runtime 只消费 `secret_ref` 或短期 `credential_ref` |
| 在 callback 中发送明文模型 key / Git token / 云密钥 | 安全红线,Manager 会拒绝疑似明文敏感字段 |
| 替 Manager 保存用户、资源绑定、审批主记录 | Manager 是控制面和记录面 |
## 12. 联调顺序建议
1. Agent Manager 确认 health、create、stop、approval decision 路径。
2. Manager 配置 `AGENT_RUNTIME_CREATE_PATH=/api/swarms` 后创建一个真实 deployment。
3. Agent Manager 回调 `task.created`、`task.claimed`、`task.heartbeat`。
4. 回调 `task.blocked`、`handoff.requested`、`handoff.completed`、`task.retried`。
5. 回调 `artifact.created`。
6. 回调 `approval.requested`,客户端/Manager approve 后 Runtime 接收 decision 并继续。
7. Manager stop,Runtime 停止任务并回调最终状态。
@@ -129,19 +129,18 @@ Backend Agent
### 7. Agent 执行闭环
Heicode 不是只把任务丢给 Agent 一次就结束,而是会在开发过程中持续调用 Agent 完成子环节。
部署后客户端拿到 agent 的公网地址,**直连 agent 持续对话推进开发**(HM 不在对话回路;agent 用模型时走 HM `/v1`)。
闭环应表达为:
```text
客户端输入目标或追加需求
-> Heicode 生成下一步任务
-> Agent 执行需求/设计/开发/测试/修复中的当前子环节
-> Agent 按需要调用已授权的 SK 工具
-> Heicode 回传中间结果给客户端
-> 用户继续追问、修正或审批
-> Agent 继续下一子环节
-> 最终由 Agent 完成交付整理与部署
客户端直连 agent,输入目标或追加需求
-> agent 自行推进需求/设计/开发/测试/修复
-> agent 按需要调用已授权的 SK 工具
-> agent 用模型时走 HM /v1(计费到用户)
-> 中间结果与产物回到客户端
-> 用户继续追问、修正或审批高危动作
-> agent 继续推进,直至交付整理与部署
```
这意味着用户看到的不是一次性“已部署 Agent”,而是一个可连续推进的开发循环。
@@ -246,13 +246,13 @@ Ops Agent 请求部署到生产环境。
这里的真实闭环是:
```text
我在客户端补充要求
-> Heicode 判断下一步要推进哪个子环节
-> Agent 执行需求、开发、测试、修复或部署中的当前任务
-> Agent 按权限调用已绑定的 SK 工具
我在客户端直连 agent、补充要求
-> agent 自行推进需求、开发、测试、修复或部署中的当前任务
-> agent 按权限调用已绑定的 SK 工具
-> agent 用模型时走 HM /v1
-> 中间结果回到客户端
-> 我继续修正方向或批准高危动作
-> Agent 继续推进直到交付和部署完成
-> agent 继续推进直到交付和部署完成
```
### 18. 查看执行状态
+64
View File
@@ -0,0 +1,64 @@
package controller
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// 客户端遥测(#24)管理员后台只读视图:列表 + 聚合。遥测仅诊断、隔离于计费,载荷无用户内容
// (仅枚举/哈希/计数/脱敏栈帧);device_id(client_id)经 user_id 可关联账号,故仅管理员可读。
func telemetryQueryFilterFromContext(c *gin.Context) model.TelemetryQueryFilter {
userId, _ := strconv.Atoi(strings.TrimSpace(c.Query("user_id")))
start, _ := strconv.ParseInt(strings.TrimSpace(c.Query("start_timestamp")), 10, 64)
end, _ := strconv.ParseInt(strings.TrimSpace(c.Query("end_timestamp")), 10, 64)
return model.TelemetryQueryFilter{
UserId: userId,
ClientId: strings.TrimSpace(c.Query("client_id")),
Platform: strings.TrimSpace(c.Query("platform")),
AppVersion: strings.TrimSpace(c.Query("app_version")),
ErrorCategory: strings.TrimSpace(c.Query("error_category")),
ErrorCode: strings.TrimSpace(c.Query("error_code")),
StartReceivedAt: start,
EndReceivedAt: end,
}
}
// AdminListTelemetryEvents: GET /api/telemetry/events — 分页列表(最新在前),支持
// user_id/client_id/platform/app_version/error_category/error_code + 时间范围过滤。
func AdminListTelemetryEvents(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
events, total, err := model.ListTelemetryEvents(telemetryQueryFilterFromContext(c), pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(events)
common.ApiSuccess(c, pageInfo)
}
// AdminAggregateTelemetryEvents: GET /api/telemetry/aggregate — 按维度聚合计数。
// dimension 默认 error_category(白名单:error_category/error_code/platform/app_version/
// os_version/arch/stack_hash);返回各桶 {key,count,users},busiest first。过滤同列表。
func AdminAggregateTelemetryEvents(c *gin.Context) {
dimension := strings.TrimSpace(c.Query("dimension"))
if dimension == "" {
dimension = "error_category"
}
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
buckets, err := model.AggregateTelemetryEvents(telemetryQueryFilterFromContext(c), dimension, limit)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"dimension": dimension,
"buckets": buckets,
})
}
@@ -0,0 +1,138 @@
package controller
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/heicode/manager/model"
"github.com/stretchr/testify/require"
)
// startTemplateAgentAsync must NOT block the caller on the (slow) AM start, and
// must flip the pre-persisted Pending record to running once AM responds — this
// is the core of the 504 fix (deploy returns immediately; status lands later).
func TestStartTemplateAgentAsync_PendingToRunning(t *testing.T) {
setupResourceControllerTestDB(t)
require.NoError(t, model.DB.AutoMigrate(&model.AgentDeployment{}))
// Slow AM mock — simulates the ~30s start the handler must not block on.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(150 * time.Millisecond)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"namespace":"rt-async-1","access_info":{"domain":"https://async.agents.example"},"status":"running"}}`))
}))
defer srv.Close()
t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL)
// What HeicodeDeployAgent persists before returning.
require.NoError(t, model.DB.Create(&model.AgentDeployment{
DeploymentID: "dep_async_run",
UserID: "22",
TemplateID: "architect",
Status: "Pending",
}).Error)
start := time.Now()
startTemplateAgentAsync("dep_async_run", 0, amStartArgs{ManagerDeploymentID: "dep_async_run", UserID: "22", TemplateKey: "architect"})
require.Less(t, time.Since(start), 50*time.Millisecond, "must not block on the AM start call")
var got model.AgentDeployment
require.Eventually(t, func() bool {
model.DB.Where("deployment_id = ?", "dep_async_run").First(&got)
return got.Status == "running"
}, 3*time.Second, 20*time.Millisecond, "Pending should flip to running after async AM start")
require.Equal(t, "rt-async-1", got.RuntimeDeploymentID)
require.Equal(t, "https://async.agents.example", got.Subdomain)
}
// On AM failure the record must be marked failed (with a reason), not left
// stuck on Pending — so the client surfaces a failure instead of a hang.
func TestStartTemplateAgentAsync_FailureMarksFailed(t *testing.T) {
setupResourceControllerTestDB(t)
require.NoError(t, model.DB.AutoMigrate(&model.AgentDeployment{}))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"success":false,"message":"AM boom"}`))
}))
defer srv.Close()
t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL)
require.NoError(t, model.DB.Create(&model.AgentDeployment{
DeploymentID: "dep_async_fail",
UserID: "22",
TemplateID: "architect",
Status: "Pending",
}).Error)
startTemplateAgentAsync("dep_async_fail", 0, amStartArgs{ManagerDeploymentID: "dep_async_fail", UserID: "22", TemplateKey: "architect"})
var got model.AgentDeployment
require.Eventually(t, func() bool {
model.DB.Where("deployment_id = ?", "dep_async_fail").First(&got)
return got.Status == "failed"
}, 3*time.Second, 20*time.Millisecond, "AM failure should mark the record failed")
require.NotEmpty(t, got.FailureReason)
}
// Concurrency boundary: if the user stops the agent while AM is still starting,
// the async goroutine must NOT resurrect it to running, and must delete the
// runtime AM just started (orphan cleanup). The AM start is held until the test
// has marked the record stopped, so the conditional update always runs after.
func TestStartTemplateAgentAsync_StoppedDuringStart_NoResurrect(t *testing.T) {
setupResourceControllerTestDB(t)
require.NoError(t, model.DB.AutoMigrate(&model.AgentDeployment{}))
release := make(chan struct{})
var mu sync.Mutex
var deletedRuntime string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/agents":
<-release // block the start until the test marks the row stopped
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"namespace":"rt-race-1","access_info":{"domain":"https://race.agents.example"},"status":"running"}}`))
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/agents/"):
mu.Lock()
deletedRuntime = strings.TrimPrefix(r.URL.Path, "/agents/")
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL)
require.NoError(t, model.DB.Create(&model.AgentDeployment{
DeploymentID: "dep_race_stop",
UserID: "22",
TemplateID: "architect",
Status: "Pending",
}).Error)
startTemplateAgentAsync("dep_race_stop", 0, amStartArgs{ManagerDeploymentID: "dep_race_stop", UserID: "22", TemplateKey: "architect"})
// User stops the agent while AM is still starting. Done before releasing AM, so
// the goroutine's Pending-guarded update is guaranteed to see "stopped".
require.NoError(t, model.DB.Model(&model.AgentDeployment{}).
Where("deployment_id = ?", "dep_race_stop").
Updates(map[string]any{"status": "stopped"}).Error)
close(release)
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return deletedRuntime == "rt-race-1"
}, 3*time.Second, 20*time.Millisecond, "orphan runtime must be deleted in AM")
var row model.AgentDeployment
require.NoError(t, model.DB.Where("deployment_id = ?", "dep_race_stop").First(&row).Error)
require.Equal(t, "stopped", row.Status, "stopped record must not be flipped back to running")
require.Empty(t, row.RuntimeDeploymentID, "runtime id must not be backfilled onto a stopped record")
}
+42
View File
@@ -29,6 +29,7 @@ type agentCallbackEnvelope struct {
TaskID string `json:"task_id"`
OccurredAt string `json:"occurred_at"`
CorrelationID string `json:"correlation_id"`
Sequence int `json:"sequence"`
Source string `json:"source"`
Metadata map[string]any `json:"metadata"`
Payload map[string]any `json:"payload"`
@@ -478,6 +479,18 @@ var agentCallbackEventRequiredFields = map[string][]string{
"handoff.requested": {"task_id", "from_role", "to_role"},
"handoff.completed": {"task_id", "from_role", "to_role"},
"approval.requested": {"approval_id", "operation", "risk_level"},
// agent_swarm event-schema FROZEN v1 新增 6 类(#15)。必填字段先按最小集登记
// (避免误拒);agent_swarm PR #28 合并后按其 §4 表精校。
"handoff.created": {"task_id"},
"approval.approved": {"approval_id"},
"approval.rejected": {"approval_id"},
"swarm.completed": {},
"swarm.failed": {"reason"},
"swarm.stopped": {},
// #60 A.5:用户全部 run 停机时 Swarm 恰好发一次,payload {user_id, secret_ref(azkv://)}。
// 控制面生命周期事件,HM 据此吊销该用户 swarm 模型 key。最小必填集避免误拒;user_id 缺失
// 时回退到 deployment 上下文。
"swarm.pool_terminated": {},
"artifact.created": {"artifact_id"},
"timeline.updated": {"title"},
"sk_tool.called": {"tool_name", "tool_invocation_id"},
@@ -503,7 +516,14 @@ var agentCallbackEventCategories = map[string]string{
"task.completed": "swarm_task_flow",
"handoff.requested": "swarm_task_flow",
"handoff.completed": "swarm_task_flow",
"handoff.created": "swarm_task_flow",
"approval.requested": "approval",
"approval.approved": "approval",
"approval.rejected": "approval",
"swarm.completed": "swarm_lifecycle",
"swarm.failed": "swarm_lifecycle",
"swarm.stopped": "swarm_lifecycle",
"swarm.pool_terminated": "swarm_lifecycle",
"artifact.created": "artifact",
"timeline.updated": "timeline",
"sk_tool.called": "sk",
@@ -645,6 +665,26 @@ func persistAgentApprovalFromCallback(payload agentCallbackEnvelope, record agen
return nil
}
// handleSwarmPoolTerminated 处理 #60 A.5 吊销握手:收到 swarm.pool_terminated 时,
// 删除该用户的 swarm 模型 key(KV 密文 + token)。user_id 优先取 payload,缺失时回退到
// deployment 上下文。仅在事件首次入库(inserted)时调用,天然幂等。
func handleSwarmPoolTerminated(payload agentCallbackEnvelope, record agentDeploymentRecord) {
if payload.EventType != "swarm.pool_terminated" {
return
}
userID := strings.TrimSpace(callbackStringValue(payload.Payload, "user_id"))
if userID == "" {
userID = strings.TrimSpace(record.Plan.UserContext.UserID)
}
uid, err := strconv.Atoi(userID)
if err != nil || uid <= 0 {
common.SysLog("swarm.pool_terminated: missing/invalid user_id; skip swarm model key revocation")
return
}
revokeSwarmModelKey(uid)
common.SysLog(fmt.Sprintf("swarm.pool_terminated: revoked swarm model key for user %d", uid))
}
func AgentReceiveRuntimeEventCallback(c *gin.Context) {
rawBody, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
@@ -709,6 +749,7 @@ func AgentReceiveRuntimeEventCallback(c *gin.Context) {
UserID: record.Plan.UserContext.UserID,
BindingScope: firstPlanBindingScope(record.Plan),
CorrelationID: strings.TrimSpace(payload.CorrelationID),
Sequence: payload.Sequence,
Source: strings.TrimSpace(payload.Source),
Result: "ok",
PayloadJSON: string(payloadJSON),
@@ -732,6 +773,7 @@ func AgentReceiveRuntimeEventCallback(c *gin.Context) {
return
}
record, _ = applyAgentCallbackDeploymentState(payload, record)
handleSwarmPoolTerminated(payload, record)
recordAgentAuditEvent(agentEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: "callback." + payload.EventType,
+30
View File
@@ -776,6 +776,16 @@ func validateOrchestrationPlan(c *gin.Context, plan agentOrchestrationPlan) bool
agentError(c, "POLICY_REJECTED", "at least one agent is required")
return false
}
// Upper bound on agents per orchestration plan (defense-in-depth on this
// admin-only control-plane path): a single call must not request an unbounded
// number of agents (resource-exhaustion / cost blow-up). Mirrors the team-tier
// ceiling (8); env-overridable, <=0 disables. The per-user deploy-COUNT cap lives
// on the user self-service path (HeicodeDeployAgent); this is the per-plan SIZE
// guard. (#8)
if maxPerPlan := common.GetEnvOrDefault("HEICODE_MAX_AGENTS_PER_PLAN", 8); maxPerPlan > 0 && len(plan.Agents) > maxPerPlan {
agentError(c, "POLICY_REJECTED", "orchestration plan exceeds the maximum of "+strconv.Itoa(maxPerPlan)+" agents")
return false
}
if strings.TrimSpace(plan.UserContext.UserID) == "" {
agentError(c, "POLICY_REJECTED", "user_context.user_id is required")
return false
@@ -1087,6 +1097,20 @@ func createAgentDeploymentFromPlan(c *gin.Context, plan agentOrchestrationPlan,
plan.Metadata.RuntimeMode = agentRuntimeModeAgent
}
// #60 模型 key 注入(方案A):swarm 模式下,若 billing_context 未自带 secret_ref,
// 则为认证用户 mint/复用 per-user sk- 写入 Key Vault,把 azkv:// 引用放进
// billing_context.secret_ref 下发给 Swarm(对接 agent_swarm PR#43)。Key Vault
// 未配置/不可达时降级:记日志、secret_ref 留空,不阻断 create(联调前可用)。
if plan.Metadata.RuntimeMode == agentRuntimeModeSwarm && strings.TrimSpace(plan.BillingContext.SecretRef) == "" {
if uid := c.GetInt("id"); uid > 0 {
if secretRef, err := provisionSwarmModelKey(uid); err != nil {
common.SysLog("provisionSwarmModelKey (swarm create): " + err.Error())
} else {
plan.BillingContext.SecretRef = secretRef
}
}
}
now := agentNow()
deploymentID := "dep_" + common.GetUUID()[:12]
record := agentDeploymentRecord{
@@ -1156,6 +1180,12 @@ func createAgentDeployment(c *gin.Context, enforceUserScope bool) {
}
func AgentCreateUserSwarm(c *gin.Context) {
// 订阅套餐 gate:蜂群按套餐开通(管理员在套餐编辑里逐个设 SwarmEnabled,不硬编码 tier)。
// 普通用户须有任一活跃套餐开通蜂群;管理员(role>=admin)绕过,便于测试/运维。
if c.GetInt("role") < common.RoleAdminUser && !model.GetUserSwarmEnabled(c.GetInt("id")) {
agentError(c, "POLICY_REJECTED", "当前订阅套餐未开通蜂群(swarm);请升级套餐或联系管理员")
return
}
record, ok := createAgentDeploymentRecord(c, true, agentRuntimeModeSwarm)
if !ok {
return
@@ -595,6 +595,28 @@ func TestAgentCreateDeploymentRejectsInvalidSubMode(t *testing.T) {
require.Empty(t, agentDeployments)
}
func TestAgentCreateDeploymentRejectsTooManyAgents(t *testing.T) {
setupAgentControlPlaneTestDB(t)
resetAgentControlPlaneState(t)
// 9 agents > default per-plan cap of 8. The size guard short-circuits before
// per-agent validation, so the filler agents need no further detail. (#8)
plan := baseAgentResourceGrantPlan()
for i := 0; i < 8; i++ {
plan.Agents = append(plan.Agents, agentAgentPlan{
RoleTemplate: "extra-" + strconv.Itoa(i),
Goal: "filler",
})
}
require.Len(t, plan.Agents, 9)
_, envelope := postAgentCreateDeployment(t, plan)
require.False(t, envelope.Success)
require.Equal(t, "POLICY_REJECTED", envelope.Error.Code)
require.Contains(t, envelope.Error.Message, "maximum of 8 agents")
require.Empty(t, agentDeployments) // rejected before any deployment is persisted
}
func TestAgentCreateDeploymentRejectsNonAzureSecretRef(t *testing.T) {
setupAgentControlPlaneTestDB(t)
resetAgentControlPlaneState(t)
@@ -986,6 +1008,9 @@ func TestAgentUserSwarmsAdapterCreatesScopedDeployment(t *testing.T) {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set("id", 7)
// 蜂群订阅 gate(PR#70):普通用户须有开通蜂群的活跃套餐;admin 绕过(便于测试/运维)。
// 本用例聚焦 adapter 的用户作用域,非 gate 本身(gate 见 TestGetUserSwarmEnabled),走 admin 旁路。
ctx.Set("role", common.RoleAdminUser)
ctx.Set("group", "development")
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/swarms", strings.NewReader(string(body)))
ctx.Request.Header.Set("Content-Type", "application/json")
@@ -0,0 +1,24 @@
package controller
import "testing"
// #55: agent endpoint 安全级别推断 —— 明文/裸主机 = none(客户端生产可拒),显式 https = tls。
func TestAgentEndpointSecurity(t *testing.T) {
cases := []struct {
in string
scheme, prof string
secure bool
}{
{"https://dep-x.agents.example", "https", "tls", true},
{"http://dep-x.agents.example", "http", "none", false},
{"dep-x.taijiagnet.com", "http", "none", false}, // 裸主机:AM 当前明文
{" HTTPS://Dep.Example ", "https", "tls", true},
{"", "http", "none", false},
}
for _, c := range cases {
sc, pr, se := agentEndpointSecurity(c.in)
if sc != c.scheme || pr != c.prof || se != c.secure {
t.Errorf("agentEndpointSecurity(%q) = (%s,%s,%v), want (%s,%s,%v)", c.in, sc, pr, se, c.scheme, c.prof, c.secure)
}
}
}
+455
View File
@@ -0,0 +1,455 @@
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(&currentAgents).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,
})
}
+153
View File
@@ -0,0 +1,153 @@
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]) // 标准确认项
}
// #41: 版本哈希对稳定安全面确定且稳定;不随易变预算/在跑数变化。
func TestComputePreflightVersion_StableAndDeterministic(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", Status: "active", HasSecret: true},
}
s1 := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 1)
s2 := computePreflight(tpl(), res, nil, 7_777_777, 500000, 5, 3) // 预算/在跑数不同
v1 := computePreflightVersion(s1)
require.Equal(t, v1, computePreflightVersion(s2), "版本不应随易变的预算/在跑数变化")
require.True(t, len(v1) > 5 && v1[:5] == "pfv1_")
// 资源顺序不影响版本(内部排序)
resReordered := []preflightResource{res[1], res[0]}
require.Equal(t, v1, computePreflightVersion(computePreflight(tpl(), resReordered, nil, 1, 500000, 5, 0)))
}
// #41: 资源篡改(改 has_secret / 增删资源)→ 版本翻转。
func TestComputePreflightVersion_ChangesOnTamper(t *testing.T) {
base := computePreflight(tpl(), []preflightResource{
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
}, nil, 1_000_000, 500000, 5, 0)
v0 := computePreflightVersion(base)
tampered := computePreflight(tpl(), []preflightResource{
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: false},
}, nil, 1_000_000, 500000, 5, 0)
require.NotEqual(t, v0, computePreflightVersion(tampered), "改 has_secret 应翻转版本")
added := computePreflight(tpl(), []preflightResource{
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
{BindingID: 2, Type: "database", Provider: "postgres", Name: "db", Status: "active", HasSecret: true},
}, nil, 1_000_000, 500000, 5, 0)
require.NotEqual(t, v0, computePreflightVersion(added), "新增资源(引入 db_write 高危)应翻转版本")
}
// #41 复审 #3:模板安全面(definition/model/name)变化 → 版本翻转(同一 template_key 被改也失效)。
func TestComputePreflightVersion_ChangesOnTemplateEdit(t *testing.T) {
res := []preflightResource{
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
}
base := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus", Definition: "v1 body"}, res, nil, 1_000_000, 500000, 5, 0)
v0 := computePreflightVersion(base)
editedDef := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus", Definition: "v2 body changed"}, res, nil, 1_000_000, 500000, 5, 0)
require.NotEqual(t, v0, computePreflightVersion(editedDef), "改 definition 应翻转版本")
editedModel := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "sonnet", Definition: "v1 body"}, res, nil, 1_000_000, 500000, 5, 0)
require.NotEqual(t, v0, computePreflightVersion(editedModel), "改 model 应翻转版本")
}
// #41: normalizeBindingIDs 去重 + 去非正数 + 保序。
func TestNormalizeBindingIDs(t *testing.T) {
require.Equal(t, []int{3, 1, 2}, normalizeBindingIDs([]int{3, 1, 3, 0, 2, -5, 1}))
require.Empty(t, normalizeBindingIDs(nil))
}
// #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]")
}
@@ -526,6 +526,29 @@ func agentRuntimeStopPath(cfg agentRuntimeConfig, runtimeDeploymentID string) st
return strings.ReplaceAll(path, "{deployment_id}", url.PathEscape(runtimeDeploymentID))
}
// agentRuntimeSwarmInputPath / agentRuntimeSwarmResultPath 构造 swarm 运行时的追加输入 / 结果
// 端点(agent_swarm PR#41 runtime-contract:POST/GET …/{deployment_id}/{input,result})。默认沿用
// create-path 的 /api/agent/swarm/deployments 基路径,可经 SWARM_RUNTIME_{INPUT,RESULT}_PATH 覆盖。
func agentRuntimeSwarmInputPath(cfg agentRuntimeConfig, runtimeDeploymentID string) string {
return swarmRuntimeDeploymentSubPath(cfg, runtimeDeploymentID, "input", "SWARM_RUNTIME_INPUT_PATH")
}
func agentRuntimeSwarmResultPath(cfg agentRuntimeConfig, runtimeDeploymentID string) string {
return swarmRuntimeDeploymentSubPath(cfg, runtimeDeploymentID, "result", "SWARM_RUNTIME_RESULT_PATH")
}
func swarmRuntimeDeploymentSubPath(cfg agentRuntimeConfig, runtimeDeploymentID, sub, envKey string) string {
path := strings.TrimSpace(common.GetEnvOrDefaultString(envKey, ""))
if path == "" {
base := strings.TrimRight(strings.TrimSpace(cfg.CreatePath), "/")
if base == "" {
base = "/api/agent/swarm/deployments"
}
path = base + "/{deployment_id}/" + sub
}
return strings.ReplaceAll(path, "{deployment_id}", url.PathEscape(strings.TrimSpace(runtimeDeploymentID)))
}
func agentRuntimeStopPathForRecord(cfg agentRuntimeConfig, record agentDeploymentRecord) string {
// {deployment_id} falls back to the swarm id so sub-agile-style paths still
// resolve for records that only persisted a runtime swarm id.
+748
View File
@@ -0,0 +1,748 @@
package controller
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"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, // 运行时真实状态(契约 §4)
"display_status": swarmDisplayStatus(dep.Status), // 客户端展示态(契约 §4.1 映射)
"goal_summary": swarmGoalSummary(dep.PlanJSON), // 单行目标(客户端 Run 列表展示;@Mem0ried #28 消费需求)
"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,
}
}
// swarmGoalSummary 从持久化的 plan_json 提取顶层 objective,折叠为单行供客户端 Run 列表展示
// (#28 消费需求:列表不止 deployment_id/status)。空白折叠 + 截断 + RedactText 兜底(objective
// 是用户目标文本而非密钥,但仍按统一红线剥离误入的 sk-/token);取不到则返回空串(不臆造)。
func swarmGoalSummary(planJSON string) string {
if strings.TrimSpace(planJSON) == "" {
return ""
}
var plan struct {
Objective string `json:"objective"`
}
if err := common.UnmarshalJsonStr(planJSON, &plan); err != nil {
return ""
}
s := strings.TrimSpace(plan.Objective)
if s == "" {
return ""
}
s = strings.Join(strings.Fields(s), " ") // 折叠所有空白(含换行)为单空格
const maxLen = 200
if len([]rune(s)) > maxLen {
s = strings.TrimSpace(string([]rune(s)[:maxLen])) + "…"
}
return model.RedactText(s)
}
// swarmDisplayStatus 把运行时真实状态映射到客户端展示态(agent_swarm runtime-contract §4.1)。
// 运行时只有 waiting_approval/running/blocked/completed/failed/stopped;`blocked` 展示为
// `degraded`,其余直通。preparing/verifying 是 running 的子态、由运行时阶段决定,HM 未单独存,
// 不臆造(规则:无信号不造态)。
func swarmDisplayStatus(status string) string {
if strings.EqualFold(strings.TrimSpace(status), "blocked") {
return "degraded"
}
return status
}
// swarmSensitivePayloadKeys 是事件 payload 中**绝不下发**给客户端的键(凭据/大字段)。
// 递归剔除(#45 复审 #1:回调 envelope 可能含 secret_ref,如 approval.requested)。
var swarmSensitivePayloadKeys = map[string]bool{
"secret_ref": true, "secretref": true, "credentials": true, "credential": true,
// agent_swarm event-schema v1(#15):envelope 按设计透传 azkv:// 引用,客户端可见视图须剔除。
"credential_ref": true, "signing_secret_ref": 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, // HM 不透明分页游标(next_after);单调,兼容 sequence 未上线
"sequence": e.Sequence, // agent_swarm v1 per-swarm 序号(客户端去重/排序;0=envelope 未带)
"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
}
// 详情视图在状态摘要上补 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= — 事件增量拉取。
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, swarmArtifactView(e))
}
}
common.ApiSuccess(c, gin.H{"items": items, "total": len(items)})
}
// swarmArtifactView 从 artifact.created 事件产出客户端扁平视图(agent_swarm event-schema v1):
// {uri, checksum, task_id, size_bytes?, created_at}。值取自(已脱敏的)payload —— uri/checksum
// 非敏感保留;**绝不含 secret_ref**。size 未知则省略(不伪造,契约要求)。
func swarmArtifactView(e model.AgentCallbackEvent) gin.H {
p := sanitizeSwarmPayload(e.PayloadJSON)
get := func(k string) any {
if p == nil {
return nil
}
return p[k]
}
createdAt := get("created_at")
if createdAt == nil || createdAt == "" {
createdAt = e.OccurredAt
}
out := gin.H{
"event_id": e.EventID,
"sequence": e.Sequence,
"task_id": firstNonNil(get("task_id"), e.TaskID),
"uri": get("uri"),
"checksum": get("checksum"),
"created_at": createdAt,
}
if sz := get("size_bytes"); sz != nil { // 未知则省略,不伪造
out["size_bytes"] = sz
}
return out
}
// firstNonNil 返回第一个非 nil/非空的值。
func firstNonNil(vals ...any) any {
for _, v := range vals {
if v != nil && v != "" {
return v
}
}
return nil
}
// HeicodeStopSwarm: POST /api/heicode/swarms/:id/stop — 停止 swarm 运行(写操作)。
// 这是唯一需要真正调用 Swarm 运行时的操作。契约已随 agent_swarm runtime-contract v1 冻结
// (#45 复审 + agent_swarm#14):POST /api/agent/swarm/deployments/{deployment_id}/stop,
// Bearer SWARM_RUNTIME_SERVICE_TOKEN,X-Idempotency-Key 幂等;运行时受理后异步停机并回推
// swarm.stopped 事件。开关 SWARM_RUNTIME_ENABLED=false(默认)或缺 base_url/token 时一律拒绝,
// **绝不伪造 accepted**(#45 复审 #2)。
func HeicodeStopSwarm(c *gin.Context) {
dep, ok := findUserSwarmDeployment(c)
if !ok {
return
}
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeSwarm)
// 开关 + base_url + 服务令牌缺一不可:任一缺失都不调用、不伪造受理。
if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" {
agentError(c, "POLICY_REJECTED",
"swarm stop not enabled: requires SWARM_RUNTIME_ENABLED=true with SWARM_RUNTIME_BASE_URL and SWARM_RUNTIME_SERVICE_TOKEN")
return
}
var body struct {
Reason string `json:"reason"`
}
_ = c.ShouldBindJSON(&body)
result, err := callSwarmRuntimeStop(c.Request.Context(), cfg, dep, body.Reason)
if err != nil {
common.SysLog("HeicodeStopSwarm: " + err.Error())
agentError(c, "RUNTIME_UNAVAILABLE", "failed to stop swarm run at runtime: "+err.Error())
return
}
// 运行时已受理停机;真正终态由运行时异步回推 swarm.stopped(经 /api/agent/callbacks)写回,
// HM 此处不抢先改写 dep.Status 以免与回调竞态。只落审计。
recordAgentAuditEvent(agentEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: "swarm.stop_requested",
SchemaVersion: 1,
UserID: dep.UserID,
ChannelID: dep.ChannelID,
BindingScope: dep.BindingScope,
DeploymentID: dep.DeploymentID,
CorrelationID: dep.CorrelationID,
OccurredAt: agentNow(),
}, "agent_swarm_query", dep.DeploymentID, agentRequestID(c), "ok")
common.ApiSuccess(c, gin.H{
"deployment_id": dep.DeploymentID,
"swarm_id": dep.RuntimeSwarmID,
"accepted": true,
"runtime_status": firstNonEmpty(result.RuntimeStatus, "stopping"),
"note": "stop accepted by runtime; final state arrives via swarm.stopped callback",
})
}
// swarmStopResult 是运行时 stop 调用的归一化结果。
type swarmStopResult struct {
RuntimeStatus string
HTTPStatus int
}
// callSwarmRuntimeStop 向 Swarm 运行时发起真实 stop(runtime-contract v1)。复用 agent_runtime_client
// 的配置/URL/信封解析 helper,但按 model.AgentDeployment 直接构造请求(无需 agentDeploymentRecord)。
// runtime id 取 runtime_deployment_id,缺失时回退 runtime_swarm_id(契约三映射)。
func callSwarmRuntimeStop(ctx context.Context, cfg agentRuntimeConfig, dep model.AgentDeployment, reason string) (swarmStopResult, error) {
runtimeID := firstNonEmpty(strings.TrimSpace(dep.RuntimeDeploymentID), strings.TrimSpace(dep.RuntimeSwarmID))
if runtimeID == "" {
return swarmStopResult{}, errors.New("swarm run has no runtime deployment id yet")
}
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeStopPath(cfg, runtimeID))
if err != nil {
return swarmStopResult{}, err
}
payload, err := common.Marshal(gin.H{
"reason": firstNonEmpty(strings.TrimSpace(reason), "Heicode Manager requested stop"),
"manager_deployment_id": dep.DeploymentID,
})
if err != nil {
return swarmStopResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return swarmStopResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.Token))
// 幂等键固定按 manager deployment_id 派生:同一 stop 重试不会在运行时侧重复执行(契约要求)。
req.Header.Set("X-Idempotency-Key", "manager-stop-"+dep.DeploymentID)
if cid := strings.TrimSpace(dep.CorrelationID); cid != "" {
req.Header.Set("X-Correlation-ID", cid)
}
client := &http.Client{Timeout: cfg.Timeout}
resp, err := client.Do(req)
if err != nil {
return swarmStopResult{}, err
}
defer resp.Body.Close()
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return swarmStopResult{HTTPStatus: resp.StatusCode}, readErr
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return swarmStopResult{HTTPStatus: resp.StatusCode}, fmt.Errorf("runtime stop returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
var envelope map[string]any
if len(respBody) > 0 {
if err := common.Unmarshal(respBody, &envelope); err != nil {
return swarmStopResult{HTTPStatus: resp.StatusCode}, err
}
}
if message := agentRuntimeEnvelopeError(envelope); message != "" {
return swarmStopResult{HTTPStatus: resp.StatusCode}, errors.New(message)
}
data := extractAgentRuntimeData(envelope)
return swarmStopResult{
RuntimeStatus: stringFromMap(data, "runtime_status", "status"),
HTTPStatus: resp.StatusCode,
}, nil
}
// callSwarmRuntimeJSON 向 Swarm 运行时发起一次 JSON 请求并归一化 envelope(复用 stop 的解析 helper)。
// 返回 data(envelope.data 或顶层)、HTTP 状态、错误。错误信息仅含 HTTP 状态 + 运行时响应体,
// 绝不含调用方请求体(如 /input 的指令文本)。
func callSwarmRuntimeJSON(ctx context.Context, cfg agentRuntimeConfig, method, endpoint string, body []byte, dep model.AgentDeployment) (map[string]any, int, error) {
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, 0, err
}
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.Token))
if cid := strings.TrimSpace(dep.CorrelationID); cid != "" {
req.Header.Set("X-Correlation-ID", cid)
}
client := &http.Client{Timeout: cfg.Timeout}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return nil, resp.StatusCode, readErr
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, resp.StatusCode, fmt.Errorf("runtime returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
var envelope map[string]any
if len(respBody) > 0 {
if err := common.Unmarshal(respBody, &envelope); err != nil {
return nil, resp.StatusCode, err
}
}
if message := agentRuntimeEnvelopeError(envelope); message != "" {
return nil, resp.StatusCode, errors.New(message)
}
return extractAgentRuntimeData(envelope), resp.StatusCode, nil
}
// HeicodeAppendSwarmInput: POST /api/heicode/swarms/:id/input — 追加用户输入(写路,代理到 Swarm)。
// agent_swarm PR#41 runtime-contract:POST …/{deployment_id}/input,注入 source=user_append 任务
// (终态 run 自动 reopen 为 running;stopped 拒绝)。**指令文本仅转发,绝不落 HM 日志/审计/事件**
// (#40「原文不进事件流」+ #46「勿落日志」)。与 stop 一样需运行时开关 + base_url + 服务令牌,缺一即拒,
// 绝不伪造受理。
func HeicodeAppendSwarmInput(c *gin.Context) {
dep, ok := findUserSwarmDeployment(c)
if !ok {
return
}
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeSwarm)
if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" {
agentError(c, "POLICY_REJECTED",
"swarm input not enabled: requires SWARM_RUNTIME_ENABLED=true with SWARM_RUNTIME_BASE_URL and SWARM_RUNTIME_SERVICE_TOKEN")
return
}
var body struct {
Instruction string `json:"instruction"`
}
if err := c.ShouldBindJSON(&body); err != nil {
agentError(c, "POLICY_REJECTED", err.Error())
return
}
if strings.TrimSpace(body.Instruction) == "" {
agentError(c, "POLICY_REJECTED", "instruction is required")
return
}
runtimeID := firstNonEmpty(strings.TrimSpace(dep.RuntimeDeploymentID), strings.TrimSpace(dep.RuntimeSwarmID))
if runtimeID == "" {
agentError(c, "POLICY_REJECTED", "swarm run has no runtime deployment id yet")
return
}
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmInputPath(cfg, runtimeID))
if err != nil {
agentError(c, "RUNTIME_UNAVAILABLE", err.Error())
return
}
payload, err := common.Marshal(gin.H{
"instruction": body.Instruction,
"manager_deployment_id": dep.DeploymentID,
})
if err != nil {
agentError(c, "POLICY_REJECTED", err.Error())
return
}
data, _, err := callSwarmRuntimeJSON(c.Request.Context(), cfg, http.MethodPost, endpoint, payload, dep)
if err != nil {
// err 仅含 HTTP 状态 + 运行时响应体,不含指令文本。
common.SysLog("HeicodeAppendSwarmInput: " + err.Error())
agentError(c, "RUNTIME_UNAVAILABLE", "failed to append input at runtime: "+err.Error())
return
}
// 审计:只记发生过一次 append,**不记指令文本**。
recordAgentAuditEvent(agentEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: "swarm.input_appended",
SchemaVersion: 1,
UserID: dep.UserID,
ChannelID: dep.ChannelID,
BindingScope: dep.BindingScope,
DeploymentID: dep.DeploymentID,
CorrelationID: dep.CorrelationID,
OccurredAt: agentNow(),
}, "agent_swarm_query", dep.DeploymentID, agentRequestID(c), "ok")
common.ApiSuccess(c, gin.H{
"deployment_id": dep.DeploymentID,
"swarm_id": dep.RuntimeSwarmID,
"accepted": true,
"task_id": stringFromMap(data, "task_id"),
"runtime_status": stringFromMap(data, "status", "runtime_status"),
"note": "input appended; original instruction is not echoed into the event stream",
})
}
// HeicodeGetSwarmResult: GET /api/heicode/swarms/:id/result — 用户面结果(读路,代理到 Swarm)。
// agent_swarm PR#41 runtime-contract:GET …/{deployment_id}/result →
// {summary, deliverable, artifacts[], termination_reason, status};产物内容按各 artifact 的 uri
// (git/runtime)取,非内联。透出前递归剔除敏感键(stripSensitiveKeys)兜底,绝不含 secret_ref 等。
// 读路:需 base_url + 服务令牌(运行时可达),不强制 SWARM_RUNTIME_ENABLED(与派发开关解耦)。
func HeicodeGetSwarmResult(c *gin.Context) {
dep, ok := findUserSwarmDeployment(c)
if !ok {
return
}
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeSwarm)
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" {
agentError(c, "POLICY_REJECTED",
"swarm result not available: requires SWARM_RUNTIME_BASE_URL and SWARM_RUNTIME_SERVICE_TOKEN")
return
}
runtimeID := firstNonEmpty(strings.TrimSpace(dep.RuntimeDeploymentID), strings.TrimSpace(dep.RuntimeSwarmID))
if runtimeID == "" {
agentError(c, "POLICY_REJECTED", "swarm run has no runtime deployment id yet")
return
}
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmResultPath(cfg, runtimeID))
if err != nil {
agentError(c, "RUNTIME_UNAVAILABLE", err.Error())
return
}
data, _, err := callSwarmRuntimeJSON(c.Request.Context(), cfg, http.MethodGet, endpoint, nil, dep)
if err != nil {
common.SysLog("HeicodeGetSwarmResult: " + err.Error())
agentError(c, "RUNTIME_UNAVAILABLE", "failed to fetch swarm result at runtime: "+err.Error())
return
}
common.ApiSuccess(c, gin.H{
"deployment_id": dep.DeploymentID,
"swarm_id": dep.RuntimeSwarmID,
"status": stringFromMap(data, "status"),
"summary": stringFromMap(data, "summary"),
"termination_reason": stringFromMap(data, "termination_reason"),
"deliverable": stripSensitiveKeys(data["deliverable"]),
"artifacts": stripSensitiveKeys(data["artifacts"]),
})
}
// swarmTerminalEventTypes 是 agent_swarm event-schema v1(#15)的运行终态事件。命中即可结束 SSE 流。
var swarmTerminalEventTypes = map[string]bool{
"swarm.completed": true,
"swarm.failed": true,
"swarm.stopped": true,
}
func swarmEventIsTerminal(eventType string) bool {
return swarmTerminalEventTypes[strings.ToLower(strings.TrimSpace(eventType))]
}
// HeicodeStreamSwarmEvents: GET /api/heicode/swarms/:id/events/stream?after= — SSE 实时事件流。
//
// 与 §events?after 同源:**全部来自 HM 已持久化的回调事件**(model.ListSwarmCallbackEventsAfter,
// 按 user_id 收口防跨用户泄漏),服务端轮询新事件后以 SSE 帧推送,**无需调用 Swarm 运行时**——
// 因此与 stop 不同,不受 SWARM_RUNTIME_ENABLED 限制。事件 payload 经 swarmEventView 脱敏。
// 进入即回放 after 之后的历史(免去客户端先调 events?after 再订阅);命中终态事件
// (swarm.completed/failed/stopped)、客户端断开或超过 maxLifetime 即结束。
func HeicodeStreamSwarmEvents(c *gin.Context) {
dep, ok := findUserSwarmDeployment(c)
if !ok {
return
}
userID := strconv.Itoa(c.GetInt("id"))
after, _ := strconv.Atoi(strings.TrimSpace(c.Query("after")))
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no") // 关掉反代缓冲,逐帧下发
const (
pollInterval = 1500 * time.Millisecond
maxLifetime = 30 * time.Minute
)
deadline := time.Now().Add(maxLifetime)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
ctx := c.Request.Context()
// flush 推送 after 之后的新事件,返回 false 表示已命中终态(应结束流)或读失败。
flush := func() bool {
events, err := model.ListSwarmCallbackEventsAfter(userID, dep.DeploymentID, dep.RuntimeSwarmID, after, 200)
if err != nil {
c.SSEvent("error", gin.H{"message": "failed to read swarm events"})
c.Writer.Flush()
return false
}
terminal := false
for _, e := range events {
c.SSEvent("message", swarmEventView(e))
if e.Id > after {
after = e.Id
}
if swarmEventIsTerminal(e.EventType) {
terminal = true
}
}
c.Writer.Flush()
return !terminal
}
if !flush() {
c.SSEvent("done", gin.H{"next_after": after, "reason": "terminal"})
c.Writer.Flush()
return
}
for {
select {
case <-ctx.Done(): // 客户端断开
return
case <-ticker.C:
if time.Now().After(deadline) {
c.SSEvent("done", gin.H{"next_after": after, "reason": "timeout"})
c.Writer.Flush()
return
}
if !flush() {
c.SSEvent("done", gin.H{"next_after": after, "reason": "terminal"})
c.Writer.Flush()
return
}
}
}
}
@@ -0,0 +1,231 @@
package controller
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/heicode/manager/model"
"github.com/stretchr/testify/require"
)
// #45 复审 #1:事件 payload 必须脱敏 —— 递归剔除 secret_ref / credentials / 大字段,
// 并对结果再跑 RedactText 兜底,绝不把 azkv:// secret_ref 或 sk-/Bearer 下发给客户端。
func TestSanitizeSwarmPayload_StripsSecrets(t *testing.T) {
raw := `{
"task_id":"t1",
"approval":{"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/git-pat","note":"deploy"},
"credentials":{"access_key":"AKIA123","secret_access_key":"xxx"},
"headers":{"authorization":"Bearer aZ09tokenVALUE"},
"api_key":"sk-abcDEF1234567890",
"stack":["plain frame","key=sk-leak0987654321ABCD"],
"ok":true
}`
out := sanitizeSwarmPayload(raw)
b, err := json.Marshal(out)
require.NoError(t, err)
s := string(b)
// 敏感键被递归剔除
require.NotContains(t, s, "secret_ref")
require.NotContains(t, s, "azkv://")
require.NotContains(t, s, "git-pat")
require.NotContains(t, s, "credentials")
require.NotContains(t, s, "AKIA123")
require.NotContains(t, s, "api_key")
// RedactText 兜底:残留在普通字段里的 sk-/Bearer 也被打码
require.NotContains(t, s, "sk-leak0987654321ABCD")
require.NotContains(t, s, "aZ09tokenVALUE")
// 非敏感内容保留
require.Contains(t, s, "t1")
require.Contains(t, s, "ok")
}
func TestSanitizeSwarmPayload_EmptyAndPlain(t *testing.T) {
require.Empty(t, sanitizeSwarmPayload(""))
out := sanitizeSwarmPayload(`{"route":"chat","n":3}`)
require.Equal(t, "chat", out["route"])
}
// #45/§4.1: 状态展示映射 —— blocked→degraded,其余直通(不臆造 preparing/verifying)。
func TestSwarmDisplayStatus(t *testing.T) {
require.Equal(t, "degraded", swarmDisplayStatus("blocked"))
require.Equal(t, "degraded", swarmDisplayStatus("BLOCKED"))
require.Equal(t, "running", swarmDisplayStatus("running"))
require.Equal(t, "completed", swarmDisplayStatus("completed"))
require.Equal(t, "waiting_approval", swarmDisplayStatus("waiting_approval"))
}
// #15: artifact.created → 扁平视图 {uri,checksum,task_id,created_at}(size 缺省省略);绝不泄 secret_ref。
func TestSwarmArtifactView(t *testing.T) {
e := model.AgentCallbackEvent{
EventID: "evt-a1", Sequence: 5, TaskID: "t9", OccurredAt: "2026-06-10T00:00:00Z",
PayloadJSON: `{"uri":"azblob://bucket/x.zip","checksum":"sha256:abc","task_id":"t9","secret_ref":"azkv://kv/secrets/s"}`,
}
v := swarmArtifactView(e)
require.Equal(t, "azblob://bucket/x.zip", v["uri"])
require.Equal(t, "sha256:abc", v["checksum"])
require.Equal(t, "t9", v["task_id"])
require.Equal(t, "2026-06-10T00:00:00Z", v["created_at"]) // payload 无 created_at → 回退 occurred_at
_, hasSize := v["size_bytes"]
require.False(t, hasSize, "size 未知不应出现")
b, _ := json.Marshal(v)
require.NotContains(t, string(b), "secret_ref")
require.NotContains(t, string(b), "azkv://")
// 带 size_bytes 时保留
e2 := model.AgentCallbackEvent{EventID: "evt-a2", PayloadJSON: `{"uri":"u","checksum":"c","size_bytes":1234,"created_at":"2026-06-10T01:00:00Z"}`}
v2 := swarmArtifactView(e2)
require.EqualValues(t, 1234, v2["size_bytes"])
require.Equal(t, "2026-06-10T01:00:00Z", v2["created_at"])
}
// #45/#15: SSE 终态判定 —— 仅 swarm.completed/failed/stopped 收流,其余继续。
func TestSwarmEventIsTerminal(t *testing.T) {
for _, et := range []string{"swarm.completed", "swarm.failed", "swarm.stopped", "SWARM.STOPPED"} {
require.True(t, swarmEventIsTerminal(et), "%s 应为终态", et)
}
for _, et := range []string{"swarm.started", "handoff.created", "approval.approved", "", "running"} {
require.False(t, swarmEventIsTerminal(et), "%s 不应为终态", et)
}
}
// #45: stop 真实接入 —— 命中冻结契约路径/鉴权/幂等头,并解析 {success,data} 信封。
func TestCallSwarmRuntimeStop(t *testing.T) {
var gotPath, gotAuth, gotIdem, gotCorr string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotIdem = r.Header.Get("X-Idempotency-Key")
gotCorr = r.Header.Get("X-Correlation-ID")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"runtime_status":"stopping"}}`))
}))
defer srv.Close()
cfg := agentRuntimeConfig{
Enabled: true,
BaseURL: srv.URL,
Token: "svc-token",
StopPath: "/api/agent/swarm/deployments/{deployment_id}/stop",
Timeout: 5 * time.Second,
}
dep := model.AgentDeployment{
DeploymentID: "dep-1",
RuntimeDeploymentID: "rt-9",
RuntimeSwarmID: "sw-7",
CorrelationID: "cor-3",
}
res, err := callSwarmRuntimeStop(context.Background(), cfg, dep, "user requested")
require.NoError(t, err)
require.Equal(t, "stopping", res.RuntimeStatus)
require.Equal(t, "/api/agent/swarm/deployments/rt-9/stop", gotPath) // runtime_deployment_id 优先
require.Equal(t, "Bearer svc-token", gotAuth)
require.Equal(t, "manager-stop-dep-1", gotIdem) // 幂等键按 manager deployment_id 派生
require.Equal(t, "cor-3", gotCorr)
}
// runtime_deployment_id 缺失时回退 runtime_swarm_id(契约三映射);无任何 runtime id 则报错不发请求。
func TestCallSwarmRuntimeStop_RuntimeIDFallbackAndMissing(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte(`{"success":true,"data":{}}`))
}))
defer srv.Close()
cfg := agentRuntimeConfig{
Enabled: true, BaseURL: srv.URL, Token: "t",
StopPath: "/api/agent/swarm/deployments/{deployment_id}/stop", Timeout: 5 * time.Second,
}
_, err := callSwarmRuntimeStop(context.Background(), cfg, model.AgentDeployment{DeploymentID: "d", RuntimeSwarmID: "sw-only"}, "")
require.NoError(t, err)
require.Equal(t, "/api/agent/swarm/deployments/sw-only/stop", gotPath)
_, err = callSwarmRuntimeStop(context.Background(), cfg, model.AgentDeployment{DeploymentID: "d"}, "")
require.Error(t, err) // 无 runtime id → 不发请求
}
// 运行时非 2xx → 返回错误(handler 据此回 RUNTIME_UNAVAILABLE,不伪造受理)。
func TestCallSwarmRuntimeStop_RuntimeError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`upstream down`))
}))
defer srv.Close()
cfg := agentRuntimeConfig{
Enabled: true, BaseURL: srv.URL, Token: "t",
StopPath: "/api/agent/swarm/deployments/{deployment_id}/stop", Timeout: 5 * time.Second,
}
_, err := callSwarmRuntimeStop(context.Background(), cfg, model.AgentDeployment{DeploymentID: "d", RuntimeDeploymentID: "rt"}, "")
require.Error(t, err)
}
// #15: 6 类新事件已注册(类别 + 必填字段两张表)。
func TestSwarmFrozenEventTypesRegistered(t *testing.T) {
for _, et := range []string{
"swarm.completed", "swarm.failed", "swarm.stopped",
"approval.approved", "approval.rejected", "handoff.created",
} {
_, inCat := agentCallbackEventCategories[et]
_, inReq := agentCallbackEventRequiredFields[et]
require.True(t, inCat, "event_type %s 应在 categories 注册", et)
require.True(t, inReq, "event_type %s 应在 requiredFields 注册", et)
}
}
// #28(@Mem0ried 消费需求):goal_summary 从 plan_json 顶层 objective 提取 —— 折叠空白为单行、
// 截断、脱敏;无 plan / 无 objective / 坏 JSON 返回空串(不臆造)。
func TestSwarmGoalSummary(t *testing.T) {
require.Empty(t, swarmGoalSummary(""))
require.Empty(t, swarmGoalSummary("not-json"))
require.Empty(t, swarmGoalSummary(`{"sub_mode":"swarm"}`)) // 无 objective
// 多行/多空格折叠成单行(JSON 里的 \n 是合法转义,用原始串避免再转义)
got := swarmGoalSummary(`{"objective":" 迁移 支付服务\n 到 K8s "}`)
require.Equal(t, "迁移 支付服务 到 K8s", got)
// 误入的 sk- 被 RedactText 兜底
red := swarmGoalSummary(`{"objective":"deploy with key sk-abcdEFGH1234567890XYZ now"}`)
require.NotContains(t, red, "sk-abcdEFGH1234567890XYZ")
// 超长截断带省略号
long := `{"objective":"` + strings.Repeat("x", 400) + `"}`
out := swarmGoalSummary(long)
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"])
}
+258 -30
View File
@@ -1,9 +1,12 @@
package controller
import (
"context"
"crypto/subtle"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -36,6 +39,23 @@ func sealAgentToken(token string) string {
return sealed
}
// agentTokenSealKeyConfigured reports whether a stable, explicitly-configured
// CRYPTO_SECRET is present so the per-agent access_token can be sealed with a
// key that survives a container restart (#31).
//
// Subtle but important: common.CryptoSecret is NEVER literally "" — it defaults
// to uuid.New() (constants.go) and, when CRYPTO_SECRET is unset, falls back to
// SessionSecret (init.go). So the "CryptoSecret unset?" plaintext fallback in
// sealAgentToken is effectively unreachable; the real production hazard is a
// key that is not stable across restarts. If CRYPTO_SECRET is not explicitly
// set, the seal key may be an ephemeral random UUID, so every previously sealed
// agent token becomes undecryptable after the next restart. We therefore gate
// agent deployment on CRYPTO_SECRET being explicitly configured. Pure helper so
// the policy is unit-testable without mutating process env globals.
func agentTokenSealKeyConfigured(cryptoSecretEnv string) bool {
return strings.TrimSpace(cryptoSecretEnv) != ""
}
// unsealAgentToken reverses sealAgentToken. If the stored value is not a sealed
// blob (legacy plaintext / CryptoSecret unset), it is returned as-is.
func unsealAgentToken(stored string) string {
@@ -91,6 +111,7 @@ func templateAgentResponse(row model.AgentDeployment) gin.H {
if strings.TrimSpace(row.BindingIDsJSON) != "" {
_ = common.UnmarshalJsonStr(row.BindingIDsJSON, &bindingIDs)
}
scheme, profile, secure := agentEndpointSecurity(row.Subdomain)
return gin.H{
"agent_id": row.DeploymentID,
"template_id": row.TemplateID,
@@ -101,6 +122,30 @@ func templateAgentResponse(row model.AgentDeployment) gin.H {
"runtime_id": row.RuntimeDeploymentID,
"created_at": row.CreatedAtText,
"updated_at": row.UpdatedAtText,
// #55: A2A 直连安全元数据。客户端据此在生产强制 HTTPS(HEICODE_AGENT_REQUIRE_SECURE):
// scheme=http/https,security_profile=none/tls,secure=profile!=none。AM 启用 HTTPS/
// mTLS listener 是 AM(azgy)的事;HM 只如实回传当前子域安全级别,明文 http →
// security_profile=none,客户端可拒绝并提示(元数据不含任何 secret_ref)。
"security": gin.H{
"scheme": scheme,
"security_profile": profile,
"secure": secure,
},
}
}
// agentEndpointSecurity 从子域字符串推断 A2A 直连的安全级别(#55)。纯函数,可单测。
// 显式 https:// → (https, tls, true);显式 http:// 或裸主机(AM 当前默认明文) → (http, none, false)。
// 保守口径:无法确证 TLS 即视为 none,宁可让客户端在生产拒绝,也不回传"看似安全"的明文端点。
func agentEndpointSecurity(subdomain string) (scheme, profile string, secure bool) {
s := strings.TrimSpace(strings.ToLower(subdomain))
switch {
case strings.HasPrefix(s, "https://"):
return "https", "tls", true
case strings.HasPrefix(s, "http://"):
return "http", "none", false
default:
return "http", "none", false // 裸主机:AM 当前明文 HTTP,保守标 none
}
}
@@ -115,9 +160,24 @@ func HeicodeDeployAgent(c *gin.Context) {
agentError(c, "POLICY_REJECTED", "authentication required")
return
}
// #31: refuse to deploy when the per-agent access_token cannot be sealed with
// a stable key. Without an explicit CRYPTO_SECRET the seal key falls back to
// an ephemeral random value, so the sealed token becomes undecryptable after
// the next restart — the agent would silently lose its credential. A dev-only
// override keeps local runs (no CRYPTO_SECRET) working; it must never be set
// in production.
if !agentTokenSealKeyConfigured(os.Getenv("CRYPTO_SECRET")) &&
!common.GetEnvOrDefaultBool("HEICODE_ALLOW_PLAINTEXT_AGENT_TOKEN_IN_DEV", false) {
common.SysLog("agent deploy rejected: CRYPTO_SECRET not set (per-agent access_token cannot be sealed with a restart-stable key)")
agentError(c, "POLICY_REJECTED", "agent deployment is disabled until CRYPTO_SECRET is configured, so the per-agent access token can be sealed with a key that survives restarts (dev-only override: HEICODE_ALLOW_PLAINTEXT_AGENT_TOKEN_IN_DEV=true)")
return
}
var req struct {
TemplateID string `json:"template_id"`
BindingIDs []int `json:"binding_ids"`
PreflightVersion string `json:"preflight_version"`
}
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
agentError(c, "POLICY_REJECTED", "invalid request body")
@@ -136,6 +196,37 @@ func HeicodeDeployAgent(c *gin.Context) {
return
}
// #41: preflight 防篡改确认校验。默认仅在客户端带了 preflight_version 时校验(向后兼容);
// HEICODE_PREFLIGHT_REQUIRED=true 时强制要求。确认后资源/模板/高危面漂移或被篡改 → 版本不匹配 → 拒绝。
if ok, msg := verifyDeployPreflight(userID, req.TemplateID, req.BindingIDs, req.PreflightVersion); !ok {
agentError(c, "POLICY_REJECTED", msg)
return
}
// Enforce the per-user deployed-agent cap (产品文档「个人5/团队8」). The cap is
// tier-aware (#8): it is the highest MaxAgents among the user's active
// subscription plans, falling back to env HEICODE_MAX_AGENTS_PER_USER
// (default 5) when no active plan sets one. So 团队 8 = an admin-configured
// team plan's MaxAgents; 个人 5 = the default — no hard-coded tier guess.
// Only the user's own non-stopped template agents count — a stopped agent
// consumes no runtime, so it is excluded. LOWER(status) keeps the comparison
// portable across SQLite/MySQL/PostgreSQL. maxAgents<=0 means "unlimited"
// (escape hatch). Closes the over-deployment hole across repeated calls.
envMaxAgents := common.GetEnvOrDefault("HEICODE_MAX_AGENTS_PER_USER", 5)
if maxAgents := model.GetUserMaxAgents(userID, envMaxAgents); maxAgents > 0 {
var active int64
if err := model.DB.Model(&model.AgentDeployment{}).
Where("user_id = ? AND template_id <> '' AND LOWER(status) <> ?", strconv.Itoa(userID), "stopped").
Count(&active).Error; err != nil {
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to check agent quota")
return
}
if active >= int64(maxAgents) {
agentError(c, "POLICY_REJECTED", "agent deployment limit reached ("+strconv.Itoa(maxAgents)+"); stop or delete an existing agent first")
return
}
}
// Load the HM-maintained template (its .md definition is sent to AM).
tpl, ok := loadAgentTemplate(req.TemplateID)
if !ok {
@@ -175,33 +266,22 @@ func HeicodeDeployAgent(c *gin.Context) {
env["AGENT_ACCESS_TOKEN"] = agentAccessToken
env["HEICODE_AGENT_ID"] = deploymentID
// Ask AM to start the agent with the template definition (.md) + env injected.
result, err := amStartTemplateAgent(c.Request.Context(), amStartArgs{
ManagerDeploymentID: deploymentID,
UserID: strconv.Itoa(userID),
TemplateKey: tpl.TemplateKey,
AgentDefinition: tpl.Definition,
Model: tpl.Model,
Env: env,
CallbackURL: agentRuntimeCallbackURL(),
})
if err != nil {
revokeAgentModelToken(modelTokenID) // don't leak the minted key
agentError(c, "RUNTIME_UNAVAILABLE", "failed to start agent: "+err.Error())
return
}
// Persist the agent as Pending and return immediately; start it in AM in the
// background. Starting an agent in AM takes ~30s — longer than the Azure
// gateway's ~20s timeout — so doing it inside the request returned 504, which
// cancelled the request context, aborted the AM call and rolled the deploy
// back. Now the client gets the Pending agent at once and polls
// GET /api/heicode/agents (which refreshes live status from AM), seeing
// Pending -> running once AM is up (or -> failed if AM start failed).
now := agentNow()
nowMs := time.Now().UnixMilli()
row := model.AgentDeployment{
DeploymentID: deploymentID,
UserID: strconv.Itoa(userID),
TemplateID: req.TemplateID,
Subdomain: result.Subdomain,
AccessToken: sealAgentToken(agentAccessToken),
BindingIDsJSON: string(bindingIDsJSON),
RuntimeDeploymentID: result.RuntimeID,
Status: firstNonEmpty(result.Status, "running"),
Status: "Pending",
ModelTokenID: modelTokenID,
CreatedAtText: now,
UpdatedAtText: now,
@@ -211,19 +291,102 @@ func HeicodeDeployAgent(c *gin.Context) {
if err := model.DB.Create(&row).Error; err != nil {
common.SysLog("HeicodeDeployAgent persist: " + err.Error())
revokeAgentModelToken(modelTokenID)
// We started an agent in AM but failed to record it: roll back the
// orphan so it does not leak/keep running with no Manager record.
if strings.TrimSpace(result.RuntimeID) != "" {
if delErr := amDeleteTemplateAgent(c.Request.Context(), result.RuntimeID); delErr != nil {
common.SysLog("HeicodeDeployAgent orphan cleanup failed: " + delErr.Error())
}
}
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist agent")
return
}
startTemplateAgentAsync(deploymentID, modelTokenID, amStartArgs{
ManagerDeploymentID: deploymentID,
UserID: strconv.Itoa(userID),
TemplateKey: tpl.TemplateKey,
AgentDefinition: tpl.Definition,
Model: tpl.Model,
Env: env,
CallbackURL: agentRuntimeCallbackURL(),
})
common.ApiSuccess(c, templateAgentResponse(row))
}
// startTemplateAgentAsync starts the template agent in AM off the request path
// and records the outcome. The deploy handler returns the Pending agent at once
// so it never blocks ~30s on AM (which exceeded the Azure gateway timeout → 504
// + rollback). Uses context.Background() because the request context is gone
// once the handler returned; the AM HTTP call is still bounded by amTemplateDo's
// own client timeout. The client observes Pending -> running (or -> failed) by
// polling the agent list, which refreshes live status from AM.
func startTemplateAgentAsync(deploymentID string, modelTokenID int, args amStartArgs) {
go func() {
result, err := amStartTemplateAgent(context.Background(), args)
if model.DB == nil {
return
}
now := agentNow()
nowMs := time.Now().UnixMilli()
if err != nil {
common.SysLog("startTemplateAgentAsync: AM start failed for " + deploymentID + ": " + err.Error())
reason := err.Error()
if len(reason) > 480 {
reason = strings.ToValidUTF8(reason[:480], "")
}
// Only flip Pending->failed. If the user stopped/deleted/cancelled during
// the AM start window the status is no longer Pending (or the row is gone),
// and we must NOT clobber that terminal intent. The status guard makes the
// transition own-or-nothing; LOWER() because the row is stored as "Pending".
res := model.DB.Model(&model.AgentDeployment{}).
Where("deployment_id = ? AND LOWER(status) = ?", deploymentID, "pending").
Updates(map[string]any{
"status": "failed",
"failure_reason": reason,
"updated_at_text": now,
"updated_at_ms": nowMs,
})
// Revoke the minted key ONLY when we owned the Pending->failed transition
// (the agent never started). If the user already acted, leave the token to
// that path: delete revokes it, stop intentionally retains it. Avoids both
// double-revoke and revoking a key the stop path means to keep.
if res.Error == nil && res.RowsAffected > 0 {
revokeAgentModelToken(modelTokenID)
}
return
}
// Success: flip Pending->running and fill in the runtime. Same status guard —
// if the user stopped/deleted/cancelled while AM was starting, this updates 0
// rows and we must clean up the now-orphaned runtime instead of resurrecting it.
res := model.DB.Model(&model.AgentDeployment{}).
Where("deployment_id = ? AND LOWER(status) = ?", deploymentID, "pending").
Updates(map[string]any{
"subdomain": result.Subdomain,
"runtime_deployment_id": result.RuntimeID,
"status": firstNonEmpty(result.Status, "running"),
"updated_at_text": now,
"updated_at_ms": nowMs,
})
if res.Error != nil {
common.SysLog("startTemplateAgentAsync: persist result failed for " + deploymentID + ": " + res.Error.Error())
return
}
if res.RowsAffected == 0 {
// No longer Pending: the user stopped/deleted/cancelled while AM was still
// starting. Re-read to log which case, then delete the orphan runtime AM
// just started — the action is identical either way (clean up, never
// resurrect), so the record is never flipped back to running. Token handling
// is left to the user's stop/delete path (delete revoked it; stop keeps it).
var cur model.AgentDeployment
if model.DB.Where("deployment_id = ?", deploymentID).First(&cur).Error != nil {
common.SysLog("startTemplateAgentAsync: " + deploymentID + " deleted during AM start; cleaning orphan runtime")
} else {
common.SysLog("startTemplateAgentAsync: " + deploymentID + " no longer Pending (status=" + cur.Status + ") during AM start; cleaning orphan runtime, preserving user state")
}
if strings.TrimSpace(result.RuntimeID) != "" {
if delErr := amDeleteTemplateAgent(context.Background(), result.RuntimeID); delErr != nil {
common.SysLog("startTemplateAgentAsync orphan cleanup failed for " + deploymentID + ": " + delErr.Error())
}
}
}
}()
}
// findUserTemplateAgent loads a deployed template agent owned by the caller.
func findUserTemplateAgent(c *gin.Context) (model.AgentDeployment, bool) {
var row model.AgentDeployment
@@ -256,6 +419,14 @@ func HeicodeListAgents(c *gin.Context) {
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list agents")
return
}
// Refresh live status from AM for every non-terminal agent BEFORE returning,
// so the list (and the desktop client that polls it) reflects reality without
// needing the detail panel to be opened. Previously only the detail/status
// endpoints refreshed, so a freshly-deployed agent stayed "Pending" in the
// list forever. Bounded: per-user agent count is capped (≤8) and the refreshes
// run concurrently under a short deadline, so the list never hangs on a slow AM.
refreshAgentStatusBatch(c.Request.Context(), rows)
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
items = append(items, templateAgentResponse(row))
@@ -263,9 +434,31 @@ func HeicodeListAgents(c *gin.Context) {
common.ApiSuccess(c, gin.H{"items": items, "total": len(items)})
}
// refreshAgentStatusBatch concurrently refreshes the live status of every
// non-terminal agent in rows (best-effort) under a single bounded deadline.
// Each row is updated in place + persisted by refreshAgentStatus.
func refreshAgentStatusBatch(ctx context.Context, rows []model.AgentDeployment) {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
var wg sync.WaitGroup
for i := range rows {
row := &rows[i]
if strings.TrimSpace(row.RuntimeDeploymentID) == "" ||
strings.EqualFold(strings.TrimSpace(row.Status), "stopped") {
continue
}
wg.Add(1)
go func(r *model.AgentDeployment) {
defer wg.Done()
refreshAgentStatus(ctx, r)
}(row)
}
wg.Wait()
}
// refreshAgentStatus best-effort pulls the live status from AM and persists it.
// On any AM error it keeps the last-known status (never blocks the read).
func refreshAgentStatus(c *gin.Context, row *model.AgentDeployment) {
func refreshAgentStatus(ctx context.Context, row *model.AgentDeployment) {
if strings.TrimSpace(row.RuntimeDeploymentID) == "" {
return
}
@@ -275,7 +468,7 @@ func refreshAgentStatus(c *gin.Context, row *model.AgentDeployment) {
if strings.EqualFold(strings.TrimSpace(row.Status), "stopped") {
return
}
status, err := amGetAgentStatus(c.Request.Context(), row.RuntimeDeploymentID)
status, err := amGetAgentStatus(ctx, row.RuntimeDeploymentID)
if err != nil || strings.TrimSpace(status) == "" || status == row.Status {
return
}
@@ -299,7 +492,7 @@ func HeicodeGetAgent(c *gin.Context) {
if !ok {
return
}
refreshAgentStatus(c, &row)
refreshAgentStatus(c.Request.Context(), &row)
common.ApiSuccess(c, templateAgentResponse(row))
}
@@ -310,7 +503,7 @@ func HeicodeGetAgentStatus(c *gin.Context) {
if !ok {
return
}
refreshAgentStatus(c, &row)
refreshAgentStatus(c.Request.Context(), &row)
common.ApiSuccess(c, gin.H{
"agent_id": row.DeploymentID,
"status": row.Status,
@@ -318,6 +511,41 @@ func HeicodeGetAgentStatus(c *gin.Context) {
})
}
// HeicodeGetAgentUsage: GET /api/heicode/agents/:deployment_id/usage
// Per-agent model-usage rollup (#9): the agent calls HM /v1/* with its minted
// token named "agent:<deployment_id>", so its consumption is the sum of consume
// logs under that token name. Optional ?start=&end= unix-second window. Returns
// raw quota + quota_per_unit (caller converts, same contract as /api/heicode/self).
func HeicodeGetAgentUsage(c *gin.Context) {
row, ok := findUserTemplateAgent(c)
if !ok {
return
}
start, _ := strconv.ParseInt(strings.TrimSpace(c.Query("start")), 10, 64)
end, _ := strconv.ParseInt(strings.TrimSpace(c.Query("end")), 10, 64)
userID := c.GetInt("id")
usage, err := model.SumAgentUsage(userID, "agent:"+row.DeploymentID, start, end)
if err != nil {
agentError(c, "DEPLOYMENT_CONFLICT", "failed to aggregate agent usage")
return
}
// #9: budget_remaining — 用户钱包剩余额度。Agent 模型调用经隐藏 token 计费到
// user.Quota(见 #30),故"预算剩余"= 用户剩余额度。-1 表示读取失败(不阻断用量展示)。
budgetRemaining := int64(-1)
if u, uErr := model.GetUserById(userID, false); uErr == nil && u != nil {
budgetRemaining = int64(u.Quota)
}
common.ApiSuccess(c, gin.H{
"agent_id": row.DeploymentID,
"quota": usage.Quota,
"prompt_tokens": usage.PromptTokens,
"completion_tokens": usage.CompletionTokens,
"call_count": usage.CallCount,
"quota_per_unit": common.QuotaPerUnit,
"budget_remaining": budgetRemaining,
})
}
// HeicodeStopAgent: POST /api/heicode/agents/:deployment_id/stop
func HeicodeStopAgent(c *gin.Context) {
row, ok := findUserTemplateAgent(c)
+10
View File
@@ -264,3 +264,13 @@ func TestTemplateAgentResponse(t *testing.T) {
require.Equal(t, []int{1, 2}, resp["binding_ids"])
require.Equal(t, "running", resp["status"])
}
// #31: agent deployment requires an explicitly configured CRYPTO_SECRET so the
// per-agent access_token is sealed with a key that survives a container restart.
// common.CryptoSecret is never literally "" (it defaults to uuid/SessionSecret),
// so the gate must key off the CRYPTO_SECRET env value, not the runtime var.
func TestAgentTokenSealKeyConfigured(t *testing.T) {
require.False(t, agentTokenSealKeyConfigured(""))
require.False(t, agentTokenSealKeyConfigured(" "))
require.True(t, agentTokenSealKeyConfigured("a-real-secret"))
}
@@ -0,0 +1,296 @@
package controller
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// AWS resource discovery (#5). Pure-Go AWS Signature V4 (no AWS SDK), mirroring
// the REST approach of azure_resource_discovery.go. Uses the Resource Groups
// Tagging API GetResources (AWS JSON 1.1), then normalizes ARNs to discoveredCloudResource.
//
// ⚠️ 覆盖范围:GetResources 仅返回**已打标签或曾打标签**的资源,**不覆盖完全未打标签的
// EC2/RDS/S3 等**——本函数(discoverAWSTagged)是「**tagged resources only**」的一半。
// 全量发现由 discoverAWS(aws_resource_discovery_services.go,#62)把本结果与服务级
// List/Describe(DescribeInstances / DescribeDBInstances / ListBuckets / ListFunctions)
// 按 ARN 合并去重补齐。
//
// Creds (from cloud_account metadata/secret): access_key_id, secret_access_key,
// region, optional session_token.
type awsCloudDiscoveryProvider struct{}
func (awsCloudDiscoveryProvider) name() string { return "aws" }
type awsDiscoveryCredentials struct {
AccessKeyID string
SecretAccessKey string
SessionToken string
Region string
}
func (c awsDiscoveryCredentials) validate() error {
if strings.TrimSpace(c.AccessKeyID) == "" {
return errors.New("AWS access_key_id required")
}
if strings.TrimSpace(c.SecretAccessKey) == "" {
return errors.New("AWS secret_access_key required")
}
if strings.TrimSpace(c.Region) == "" {
return errors.New("AWS region required")
}
return nil
}
func (awsCloudDiscoveryProvider) discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) {
// region may live on the binding's external_id when not in creds.
if mapString(creds, "region") == "" && strings.TrimSpace(account.ExternalId) != "" {
creds["region"] = account.ExternalId
}
return discoverAWS(creds, time.Now(), &http.Client{Timeout: 20 * time.Second})
}
// hmacSHA256 / sha256Hex — SigV4 primitives.
func hmacSHA256(key, data []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(data)
return h.Sum(nil)
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// awsSigV4Authorization computes the SigV4 Authorization header value + x-amz-date
// for the given request. signHeaders are the headers (besides host/x-amz-date) to
// include in the signature (e.g. content-type, x-amz-target). Pure & deterministic
// given t — verified against AWS's official "get-vanilla" test vector.
func awsSigV4Authorization(method, rawURL string, signHeaders map[string]string, payload []byte,
cred awsDiscoveryCredentials, service string, t time.Time) (authorization, amzDate string, err error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", "", err
}
amzDate = t.UTC().Format("20060102T150405Z")
dateStamp := t.UTC().Format("20060102")
// Canonical headers must include host + x-amz-date. Lowercase names, trim values, sort.
headers := map[string]string{}
for k, v := range signHeaders {
headers[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
}
headers["host"] = u.Host
headers["x-amz-date"] = amzDate
if strings.TrimSpace(cred.SessionToken) != "" {
headers["x-amz-security-token"] = strings.TrimSpace(cred.SessionToken)
}
names := make([]string, 0, len(headers))
for k := range headers {
names = append(names, k)
}
sort.Strings(names)
var canonicalHeaders strings.Builder
for _, n := range names {
canonicalHeaders.WriteString(n + ":" + headers[n] + "\n")
}
signedHeaders := strings.Join(names, ";")
canonicalURI := u.EscapedPath()
if canonicalURI == "" {
canonicalURI = "/"
}
// Canonical query string: sort by key, RFC3986-encoded.
canonicalQuery := canonicalizeQuery(u.Query())
payloadHash := sha256Hex(payload)
canonicalRequest := method + "\n" + canonicalURI + "\n" + canonicalQuery + "\n" +
canonicalHeaders.String() + "\n" + signedHeaders + "\n" + payloadHash
credentialScope := dateStamp + "/" + cred.Region + "/" + service + "/aws4_request"
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex([]byte(canonicalRequest))
kDate := hmacSHA256([]byte("AWS4"+cred.SecretAccessKey), []byte(dateStamp))
kRegion := hmacSHA256(kDate, []byte(cred.Region))
kService := hmacSHA256(kRegion, []byte(service))
kSigning := hmacSHA256(kService, []byte("aws4_request"))
signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign)))
authorization = fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
cred.AccessKeyID, credentialScope, signedHeaders, signature)
return authorization, amzDate, nil
}
func canonicalizeQuery(values url.Values) string {
if len(values) == 0 {
return ""
}
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
vs := append([]string(nil), values[k]...)
sort.Strings(vs)
for _, v := range vs {
parts = append(parts, awsURIEncode(k, true)+"="+awsURIEncode(v, true))
}
}
return strings.Join(parts, "&")
}
// awsURIEncode is RFC3986 percent-encoding per SigV4 rules.
func awsURIEncode(s string, encodeSlash bool) string {
var b strings.Builder
for _, c := range []byte(s) {
switch {
case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~':
b.WriteByte(c)
case c == '/' && !encodeSlash:
b.WriteByte(c)
default:
b.WriteString(fmt.Sprintf("%%%02X", c))
}
}
return b.String()
}
// awsResourceTagMapping is one entry of the Resource Groups Tagging API response.
type awsResourceTagMapping struct {
ResourceARN string `json:"ResourceARN"`
Tags []struct {
Key string `json:"Key"`
Value string `json:"Value"`
} `json:"Tags"`
}
type awsGetResourcesResponse struct {
ResourceTagMappingList []awsResourceTagMapping `json:"ResourceTagMappingList"`
PaginationToken string `json:"PaginationToken"`
}
// discoverAWSTagged enumerates the account's tagged resources via the Resource Groups
// Tagging API (paginated). Real implementation (no mock). Coverage is "tagged only"
// (see file header); discoverAWS (aws_resource_discovery_services.go) merges this with
// service-level List/Describe for full-account coverage (#62).
func discoverAWSTagged(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
endpoint := fmt.Sprintf("https://tagging.%s.amazonaws.com/", cred.Region)
const target = "ResourceGroupsTaggingAPI_20170126.GetResources"
out := []discoveredCloudResource{}
paginationToken := ""
for page := 0; page < 50; page++ {
bodyMap := map[string]any{"ResourcesPerPage": 100}
if paginationToken != "" {
bodyMap["PaginationToken"] = paginationToken
}
body, err := common.Marshal(bodyMap)
if err != nil {
return nil, err
}
signHeaders := map[string]string{
"content-type": "application/x-amz-json-1.1",
"x-amz-target": target,
}
auth, amzDate, err := awsSigV4Authorization(http.MethodPost, endpoint, signHeaders, body, cred, "tagging", now)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
req.Header.Set("X-Amz-Target", target)
req.Header.Set("X-Amz-Date", amzDate)
req.Header.Set("Authorization", auth)
if strings.TrimSpace(cred.SessionToken) != "" {
req.Header.Set("X-Amz-Security-Token", cred.SessionToken)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("AWS GetResources request failed: %w", err)
}
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("AWS GetResources failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var payload awsGetResourcesResponse
if err := common.Unmarshal(raw, &payload); err != nil {
return nil, err
}
for _, m := range payload.ResourceTagMappingList {
if d, ok := awsARNToResource(m.ResourceARN); ok {
tags := map[string]any{}
for _, t := range m.Tags {
tags[t.Key] = t.Value
}
d.Metadata = map[string]any{"region": cred.Region, "tags": tags}
out = append(out, d)
}
}
paginationToken = strings.TrimSpace(payload.PaginationToken)
if paginationToken == "" {
break
}
}
return out, nil
}
// awsARNToResource parses arn:partition:service:region:account:resource into a
// normalized resource. NativeType is CloudFormation-style (AWS::<Service>::<Type>)
// so classifyCloudResourceType maps it to vm/database/blob consistently.
func awsARNToResource(arn string) (discoveredCloudResource, bool) {
arn = strings.TrimSpace(arn)
if !strings.HasPrefix(arn, "arn:") {
return discoveredCloudResource{}, false
}
parts := strings.SplitN(arn, ":", 6)
if len(parts) < 6 {
return discoveredCloudResource{}, false
}
service := parts[2]
region := parts[3]
resource := parts[5]
// resource may be "type/id", "type:id", or just "id" (e.g. s3 bucket).
resType, resName := "", resource
if i := strings.IndexAny(resource, "/:"); i >= 0 {
resType = resource[:i]
resName = resource[i+1:]
}
native := "AWS::" + strings.ToUpper(service)
if resType != "" {
native += "::" + resType
} else if strings.EqualFold(service, "s3") {
native += "::Bucket"
}
name := resName
if name == "" {
name = arn
}
return discoveredCloudResource{
ExternalId: arn,
Name: name,
NativeType: native,
Location: region,
BindingScope: "aws:" + arn,
}, true
}
@@ -0,0 +1,401 @@
package controller
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/heicode/manager/common"
)
// Service-level AWS discovery (#62): augments the tagged-only GetResources pass
// (aws_resource_discovery.go) with direct List/Describe so **untagged** EC2/RDS/S3/Lambda
// resources are found too — closing the "tagged only ≠ full account" gap (#5 follow-up).
//
// 设计:
// - discoverAWS 编排:先 tagged(最全的标签信息),再各服务级 List/Describe,按 ARN 合并去重
// (tagged 优先保留,信息更丰富)。
// - **失败降级**:任一服务调用失败(如缺该服务读权限 / AccessDenied)只记日志并跳过,绝不让
// 整次发现失败;仅当「tagged 报错且无任何服务返回结果」时,才把 tagged 的原始错误抛出,
// 好让账号凭据/区域问题对用户可见。
// - 复用 awsSigV4Authorization(已对 AWS get-vanilla 向量验证)签 GET;无 AWS SDK 依赖。
// - 区域:EC2/RDS/Lambda 用账号配置的 region;S3 ListBuckets 是全局接口(以 us-east-1 签名)。
// 跨 region 全量扫描不在本次范围(EC2/RDS 仅扫配置 region),由 #62 备注另行跟踪。
// - 解析逻辑(parseAWS* )为纯函数,便于用真实响应样本单测。
// discoverAWS enumerates the account's resources by merging the tagged-resources pass
// with service-level List/Describe (#62). Replaces the old tagged-only discoverAWS.
func discoverAWS(creds map[string]any, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
cred := awsDiscoveryCredentials{
AccessKeyID: mapString(creds, "access_key_id"),
SecretAccessKey: mapString(creds, "secret_access_key"),
SessionToken: mapString(creds, "session_token"),
Region: mapString(creds, "region"),
}
if err := cred.validate(); err != nil {
return nil, err
}
var lists [][]discoveredCloudResource
tagged, taggedErr := discoverAWSTagged(cred, now, httpClient)
if taggedErr != nil {
common.SysLog("discoverAWS tagged: " + taggedErr.Error())
} else {
lists = append(lists, tagged) // tagged 先入,合并时优先保留
}
services := []struct {
name string
fn func(awsDiscoveryCredentials, time.Time, *http.Client) ([]discoveredCloudResource, error)
}{
{"ec2", discoverAWSEC2},
{"rds", discoverAWSRDS},
{"s3", discoverAWSS3},
{"lambda", discoverAWSLambda},
}
for _, svc := range services {
list, err := svc.fn(cred, now, httpClient)
if err != nil {
common.SysLog("discoverAWS " + svc.name + ": " + err.Error())
continue
}
lists = append(lists, list)
}
merged := mergeDedupeByExternalID(lists...)
// 全部为空且 tagged 当初报错 → 抛出原始错误,让凭据/区域问题可见(纯权限缺失则降级返回已得结果)。
if len(merged) == 0 && taggedErr != nil {
return nil, taggedErr
}
return merged, nil
}
// mergeDedupeByExternalID 按 ExternalId(ARN)合并多组发现结果,先到先得(靠前的列表优先保留),
// 保持稳定顺序。纯函数,便于单测。
func mergeDedupeByExternalID(lists ...[]discoveredCloudResource) []discoveredCloudResource {
seen := map[string]bool{}
out := make([]discoveredCloudResource, 0)
for _, list := range lists {
for _, d := range list {
id := strings.TrimSpace(d.ExternalId)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, d)
}
}
return out
}
// awsSignedGet 发起一次 SigV4 签名的 GET(空 body),返回响应体。非 2xx 视为错误。
func awsSignedGet(cred awsDiscoveryCredentials, rawURL, service string, now time.Time, httpClient *http.Client) ([]byte, error) {
auth, amzDate, err := awsSigV4Authorization(http.MethodGet, rawURL, map[string]string{}, nil, cred, service, now)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Amz-Date", amzDate)
req.Header.Set("Authorization", auth)
if strings.TrimSpace(cred.SessionToken) != "" {
req.Header.Set("X-Amz-Security-Token", cred.SessionToken)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return body, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
// ---- EC2 DescribeInstances (Query 协议, XML) ----
type ec2DescribeInstancesResponse struct {
XMLName xml.Name `xml:"DescribeInstancesResponse"`
Reservations []struct {
OwnerID string `xml:"ownerId"`
Instances []struct {
InstanceID string `xml:"instanceId"`
InstanceType string `xml:"instanceType"`
State struct {
Name string `xml:"name"`
} `xml:"instanceState"`
Placement struct {
AvailabilityZone string `xml:"availabilityZone"`
} `xml:"placement"`
Tags []struct {
Key string `xml:"key"`
Value string `xml:"value"`
} `xml:"tagSet>item"`
} `xml:"instancesSet>item"`
} `xml:"reservationSet>item"`
NextToken string `xml:"nextToken"`
}
// parseEC2Instances 解析 DescribeInstances XML → 归一化资源 + nextToken。纯函数。
func parseEC2Instances(body []byte, region string) ([]discoveredCloudResource, string, error) {
var r ec2DescribeInstancesResponse
if err := xml.Unmarshal(body, &r); err != nil {
return nil, "", err
}
out := []discoveredCloudResource{}
for _, res := range r.Reservations {
for _, inst := range res.Instances {
if strings.TrimSpace(inst.InstanceID) == "" {
continue
}
arn := fmt.Sprintf("arn:aws:ec2:%s:%s:instance/%s", region, res.OwnerID, inst.InstanceID)
tags := map[string]any{}
name := inst.InstanceID
for _, t := range inst.Tags {
tags[t.Key] = t.Value
if strings.EqualFold(t.Key, "Name") && strings.TrimSpace(t.Value) != "" {
name = t.Value
}
}
out = append(out, discoveredCloudResource{
ExternalId: arn,
Name: name,
NativeType: "AWS::EC2::Instance",
Location: region,
BindingScope: "aws:" + arn,
Metadata: map[string]any{
"region": region,
"instance_type": inst.InstanceType,
"state": inst.State.Name,
"tags": tags,
},
})
}
}
return out, strings.TrimSpace(r.NextToken), nil
}
func discoverAWSEC2(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
out := []discoveredCloudResource{}
nextToken := ""
for page := 0; page < 50; page++ {
q := url.Values{}
q.Set("Action", "DescribeInstances")
q.Set("Version", "2016-11-15")
q.Set("MaxResults", "100")
if nextToken != "" {
q.Set("NextToken", nextToken)
}
rawURL := fmt.Sprintf("https://ec2.%s.amazonaws.com/?%s", cred.Region, q.Encode())
body, err := awsSignedGet(cred, rawURL, "ec2", now, httpClient)
if err != nil {
return nil, err
}
page1, token, err := parseEC2Instances(body, cred.Region)
if err != nil {
return nil, err
}
out = append(out, page1...)
nextToken = token
if nextToken == "" {
break
}
}
return out, nil
}
// ---- RDS DescribeDBInstances (Query 协议, XML) ----
type rdsDescribeDBInstancesResponse struct {
XMLName xml.Name `xml:"DescribeDBInstancesResponse"`
Result struct {
Marker string `xml:"Marker"`
DBInstances []struct {
Arn string `xml:"DBInstanceArn"`
Identifier string `xml:"DBInstanceIdentifier"`
Engine string `xml:"Engine"`
Status string `xml:"DBInstanceStatus"`
AvailabilityZone string `xml:"AvailabilityZone"`
} `xml:"DBInstances>DBInstance"`
} `xml:"DescribeDBInstancesResult"`
}
// parseRDSInstances 解析 DescribeDBInstances XML → 归一化资源 + marker。纯函数。
func parseRDSInstances(body []byte, region string) ([]discoveredCloudResource, string, error) {
var r rdsDescribeDBInstancesResponse
if err := xml.Unmarshal(body, &r); err != nil {
return nil, "", err
}
out := []discoveredCloudResource{}
for _, db := range r.Result.DBInstances {
arn := strings.TrimSpace(db.Arn)
if arn == "" {
continue // 无 ARN 不入(稳定外部 id 缺失)
}
name := strings.TrimSpace(db.Identifier)
if name == "" {
name = arn
}
out = append(out, discoveredCloudResource{
ExternalId: arn,
Name: name,
NativeType: "AWS::RDS::DBInstance",
Location: region,
BindingScope: "aws:" + arn,
Metadata: map[string]any{
"region": region,
"engine": db.Engine,
"status": db.Status,
"az": db.AvailabilityZone,
},
})
}
return out, strings.TrimSpace(r.Result.Marker), nil
}
func discoverAWSRDS(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
out := []discoveredCloudResource{}
marker := ""
for page := 0; page < 50; page++ {
q := url.Values{}
q.Set("Action", "DescribeDBInstances")
q.Set("Version", "2014-10-31")
q.Set("MaxRecords", "100")
if marker != "" {
q.Set("Marker", marker)
}
rawURL := fmt.Sprintf("https://rds.%s.amazonaws.com/?%s", cred.Region, q.Encode())
body, err := awsSignedGet(cred, rawURL, "rds", now, httpClient)
if err != nil {
return nil, err
}
page1, next, err := parseRDSInstances(body, cred.Region)
if err != nil {
return nil, err
}
out = append(out, page1...)
marker = next
if marker == "" {
break
}
}
return out, nil
}
// ---- S3 ListBuckets (REST-XML, 全局) ----
type s3ListAllMyBucketsResult struct {
XMLName xml.Name `xml:"ListAllMyBucketsResult"`
Buckets []struct {
Name string `xml:"Name"`
} `xml:"Buckets>Bucket"`
}
// parseS3Buckets 解析 ListBuckets XML → 归一化资源。纯函数。S3 bucket ARN 无 region/account 段。
func parseS3Buckets(body []byte) ([]discoveredCloudResource, error) {
var r s3ListAllMyBucketsResult
if err := xml.Unmarshal(body, &r); err != nil {
return nil, err
}
out := []discoveredCloudResource{}
for _, b := range r.Buckets {
name := strings.TrimSpace(b.Name)
if name == "" {
continue
}
arn := "arn:aws:s3:::" + name
out = append(out, discoveredCloudResource{
ExternalId: arn,
Name: name,
NativeType: "AWS::S3::Bucket",
Location: "", // 全局列举不含 region;按需 GetBucketLocation(未做,避免逐桶请求)
BindingScope: "aws:" + arn,
Metadata: map[string]any{"bucket": name},
})
}
return out, nil
}
func discoverAWSS3(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
// ListBuckets 是全局接口,固定以 us-east-1 / service=s3 签名。
s3Cred := cred
s3Cred.Region = "us-east-1"
body, err := awsSignedGet(s3Cred, "https://s3.amazonaws.com/", "s3", now, httpClient)
if err != nil {
return nil, err
}
return parseS3Buckets(body)
}
// ---- Lambda ListFunctions (REST-JSON) ----
type lambdaListFunctionsResponse struct {
Functions []struct {
FunctionArn string `json:"FunctionArn"`
FunctionName string `json:"FunctionName"`
Runtime string `json:"Runtime"`
} `json:"Functions"`
NextMarker string `json:"NextMarker"`
}
// parseLambdaFunctions 解析 ListFunctions JSON → 归一化资源 + nextMarker。纯函数。
func parseLambdaFunctions(body []byte, region string) ([]discoveredCloudResource, string, error) {
var r lambdaListFunctionsResponse
if err := common.Unmarshal(body, &r); err != nil {
return nil, "", err
}
out := []discoveredCloudResource{}
for _, fn := range r.Functions {
arn := strings.TrimSpace(fn.FunctionArn)
if arn == "" {
continue
}
name := strings.TrimSpace(fn.FunctionName)
if name == "" {
name = arn
}
out = append(out, discoveredCloudResource{
ExternalId: arn,
Name: name,
NativeType: "AWS::Lambda::Function",
Location: region,
BindingScope: "aws:" + arn,
Metadata: map[string]any{"region": region, "runtime": fn.Runtime},
})
}
return out, strings.TrimSpace(r.NextMarker), nil
}
func discoverAWSLambda(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
out := []discoveredCloudResource{}
marker := ""
for page := 0; page < 50; page++ {
q := url.Values{}
q.Set("MaxItems", "50")
if marker != "" {
q.Set("Marker", marker)
}
rawURL := fmt.Sprintf("https://lambda.%s.amazonaws.com/2015-03-31/functions/?%s", cred.Region, q.Encode())
body, err := awsSignedGet(cred, rawURL, "lambda", now, httpClient)
if err != nil {
return nil, err
}
page1, next, err := parseLambdaFunctions(body, cred.Region)
if err != nil {
return nil, err
}
out = append(out, page1...)
marker = next
if marker == "" {
break
}
}
return out, nil
}
@@ -0,0 +1,269 @@
package controller
import (
"errors"
"fmt"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
"gorm.io/gorm"
)
// Cloud resource discovery — provider-agnostic layer (#5).
//
// 历史上只有 Azure 发现(controller/azure_resource_discovery.go,硬要求 provider==azure)。
// 本文件抽象出统一的 provider 接口,让 AWS / GCP 走同一条「发现 → 归一化 → 落库」路径,
// 并对 vm / database / blob 做跨云一致的分类(DoD #3)。Azure 适配器复用既有 ARM 客户端,
// 故三家行为一致;具体云的签名/鉴权在 aws_resource_discovery.go / gcp_resource_discovery.go。
// discoveredCloudResource 是各 provider 适配器输出的**归一化**资源视图。upsert 时由统一
// 路径补 source_account_id 与 classified_type,并写入 ResourceBinding(resource_type=cloud_resource)。
type discoveredCloudResource struct {
ExternalId string // ARN / Azure resource id / GCP asset name —— 跨 provider 的稳定外部 id
Name string // 展示名
NativeType string // provider 原生类型字符串(如 Microsoft.Compute/virtualMachines、AWS::EC2::Instance、compute.googleapis.com/Instance)
Location string // region / location
BindingScope string // 可选;为空时默认 "<provider>:<external_id>"
Metadata map[string]any // provider 特定附加字段
}
// cloudDiscoveryProvider 是「连接云账号 → 列资源」的统一抽象(DoD #3)。
type cloudDiscoveryProvider interface {
name() string
// discover 用 creds(已合并 metadata + secret JSON)列出 account 下的资源。
discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error)
}
func cloudDiscoveryProviderByName(provider string) (cloudDiscoveryProvider, bool) {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "azure":
return azureCloudDiscoveryProvider{}, true
case "aws", "amazon":
return awsCloudDiscoveryProvider{}, true
case "gcp", "google", "gce":
return gcpCloudDiscoveryProvider{}, true
}
return nil, false
}
// classifyCloudResourceType 把 provider 原生类型映射到跨云一致的逻辑类别(DoD #3):
// vm / database / blob / cloud_resource(兜底)。基于不区分大小写的子串匹配,覆盖三家常见服务。
func classifyCloudResourceType(nativeType string) string {
t := strings.ToLower(nativeType)
containsAny := func(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
switch {
// 数据库(放在 vm 之前:避免 "sqlvirtual..." 之类被误判为 vm;数据库特征更强)
case containsAny(t, "sql", "postgres", "mysql", "mariadb", "rds", "database", "cosmos",
"dynamodb", "spanner", "cloudsql", "bigtable", "redis", "documentdb", "memcache"):
return "database"
case containsAny(t, "virtualmachine", "ec2::instance", "ec2/instance", "compute/instance",
"compute.googleapis.com/instance", "virtualmachinescaleset", "instance"):
return "vm"
case containsAny(t, "storageaccount", "::s3::", "s3:::", "/buckets/", "storage/bucket",
"storage.googleapis.com/bucket", "blob", "bucket"):
return "blob"
default:
return "cloud_resource"
}
}
// upsertDiscoveredCloudResources 把归一化资源幂等写入 ResourceBinding(按 user+type+provider+external_id
// 去重)。统一所有 provider 的落库格式:resource_type 固定为 cloud_resource(与既有 Azure 行为一致,
// 不让发现的资源直接变成可绑定的 vm/db/blob),跨云类别放在 metadata.classified_type。
func upsertDiscoveredCloudResources(account model.ResourceBinding, provider string, discovered []discoveredCloudResource) ([]resourceResponse, error) {
items := make([]resourceResponse, 0, len(discovered))
for _, d := range discovered {
if strings.TrimSpace(d.ExternalId) == "" {
continue
}
var resource model.ResourceBinding
err := model.DB.Where(
"user_id = ? AND resource_type = ? AND provider = ? AND external_id = ?",
account.UserId, "cloud_resource", provider, d.ExternalId,
).First(&resource).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err := applyDiscoveredResourceFields(&resource, account, provider, d); err != nil {
return nil, err
}
if resource.Id == 0 {
if err := model.DB.Create(&resource).Error; err != nil {
return nil, err
}
} else if err := model.DB.Save(&resource).Error; err != nil {
return nil, err
}
items = append(items, resourceToResponse(resource))
}
return items, nil
}
// applyDiscoveredResourceFields 把一条归一化发现结果写入 ResourceBinding 字段(DB 无关,可单测)。
// 关键安全约束(#5 复审 #1):**SecretRef 恒为空**——发现出的子资源绝不继承云账号 secret_ref,
// 否则会把账号级凭据引用扩散到每个 VM/S3/DB 并经 resourceToResponse / grant manifest 下发给 agent。
// 发现是只读清单;如需 agent 读取某资源,应另行 grant/审批/最小权限 secret。
func applyDiscoveredResourceFields(resource *model.ResourceBinding, account model.ResourceBinding, provider string, d discoveredCloudResource) error {
metadata := map[string]any{}
for k, v := range d.Metadata {
metadata[k] = v
}
metadata["source_account_id"] = account.Id
metadata["classified_type"] = classifyCloudResourceType(d.NativeType)
if strings.TrimSpace(d.NativeType) != "" {
metadata["native_type"] = d.NativeType
}
if strings.TrimSpace(d.Location) != "" {
metadata["location"] = d.Location
}
metadataJSON, err := marshalResourceJSON(metadata)
if err != nil {
return err
}
permissionScopeJSON, err := marshalResourceJSON(map[string]any{"actions": []string{provider + ":read"}})
if err != nil {
return err
}
constraintsJSON, err := marshalResourceJSON(unmarshalResourceJSON(account.Constraints))
if err != nil {
return err
}
name := strings.TrimSpace(d.Name)
if name == "" {
name = d.ExternalId
}
bindingScope := strings.TrimSpace(d.BindingScope)
if bindingScope == "" {
bindingScope = fmt.Sprintf("%s:%s", provider, d.ExternalId)
}
resource.UserId = account.UserId
resource.TenantId = account.TenantId
resource.ProjectId = account.ProjectId
resource.BindingScope = bindingScope
resource.Name = name
resource.ResourceType = "cloud_resource"
resource.Provider = provider
resource.ExternalId = d.ExternalId
resource.SecretRef = "" // 见上:绝不继承账号 secret_ref
resource.Metadata = metadataJSON
resource.PermissionScope = permissionScopeJSON
resource.Constraints = constraintsJSON
resource.Status = "active"
return nil
}
// DiscoverCloudResources: POST /api/resources/:id/discover-cloud — provider-agnostic
// discovery dispatcher (#5). Routes by the account's provider to the matching
// adapter (azure/aws/gcp), then upserts via the unified path.
func DiscoverCloudResources(c *gin.Context) {
userId := c.GetInt("id")
var account model.ResourceBinding
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorMsg(c, "resource not found")
return
}
common.ApiError(c, err)
return
}
if account.ResourceType != "cloud_account" || account.Status != "active" {
common.ApiErrorMsg(c, "resource must be an active cloud_account")
return
}
impl, ok := cloudDiscoveryProviderByName(account.Provider)
if !ok {
common.ApiErrorMsg(c, "unsupported cloud provider: "+account.Provider+" (supported: azure, aws, gcp)")
return
}
// creds = metadata(非密)合并 secret JSON(密)。secret 优先级在适配器内部按需处理。
creds := unmarshalResourceJSON(account.Metadata)
if creds == nil {
creds = map[string]any{}
}
if strings.TrimSpace(account.SecretRef) != "" {
secretClient, err := newSecretStoreClientFromEnv()
if err != nil {
common.ApiError(c, err)
return
}
secretData, err := secretClient.getJSONSecret(account.SecretRef)
if err != nil {
common.ApiError(c, err)
return
}
for k, v := range secretData {
creds[k] = v // secret 覆盖同名非密字段
}
}
discovered, err := impl.discover(account, creds)
if err != nil {
common.ApiError(c, err)
return
}
items, err := upsertDiscoveredCloudResources(account, impl.name(), discovered)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"account": resourceToResponse(account),
"provider": impl.name(),
"items": items,
"discovered": len(items),
})
}
// azureCloudDiscoveryProvider 让既有 Azure ARM 发现实现统一接口(复用 azure_resource_discovery.go
// 的 client/token/list),保证三家走同一抽象。
type azureCloudDiscoveryProvider struct{}
func (azureCloudDiscoveryProvider) name() string { return "azure" }
func (azureCloudDiscoveryProvider) discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) {
cred := azureDiscoveryCredentials{
SubscriptionID: firstString(creds, nil, account.ExternalId, "subscription_id"),
TenantID: firstString(creds, nil, account.TenantId, "tenant_id"),
ClientID: mapString(creds, "client_id"),
ClientSecret: mapString(creds, "client_secret"),
}
if err := cred.validate(); err != nil {
return nil, err
}
client := newAzureResourceDiscoveryClientFromEnv()
token, err := client.clientCredentialsToken(cred)
if err != nil {
return nil, err
}
arm, err := client.listSubscriptionResources(cred.SubscriptionID, token)
if err != nil {
return nil, err
}
out := make([]discoveredCloudResource, 0, len(arm))
for _, r := range arm {
out = append(out, discoveredCloudResource{
ExternalId: r.ID,
Name: r.Name,
NativeType: r.Type,
Location: r.Location,
BindingScope: fmt.Sprintf("azure:%s:%s", cred.SubscriptionID, r.ID),
Metadata: map[string]any{
"subscription_id": cred.SubscriptionID,
"tenant_id": cred.TenantID,
"resource_group": azureResourceGroupFromID(r.ID),
"tags": r.Tags,
},
})
}
return out, nil
}
@@ -0,0 +1,268 @@
package controller
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"strings"
"testing"
"time"
"github.com/heicode/manager/model"
"github.com/stretchr/testify/require"
)
// #5: 跨云类别分类 vm/database/blob 一致。
func TestClassifyCloudResourceType(t *testing.T) {
cases := map[string]string{
// vm
"Microsoft.Compute/virtualMachines": "vm",
"AWS::EC2::Instance": "vm",
"compute.googleapis.com/Instance": "vm",
// database
"Microsoft.Sql/servers/databases": "database",
"AWS::RDS::DBInstance": "database",
"sqladmin.googleapis.com/Instance": "database",
"AWS::DynamoDB::Table": "database",
// blob / object storage
"Microsoft.Storage/storageAccounts": "blob",
"AWS::S3::Bucket": "blob",
"storage.googleapis.com/Bucket": "blob",
// fallback
"Microsoft.Network/virtualNetworks": "cloud_resource",
"AWS::IAM::Role": "cloud_resource",
}
for native, want := range cases {
require.Equal(t, want, classifyCloudResourceType(native), "classify %s", native)
}
}
// #5: ARN 解析 → 归一化 + 正确分类。
func TestAWSARNToResource(t *testing.T) {
ec2, ok := awsARNToResource("arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123")
require.True(t, ok)
require.Equal(t, "us-east-1", ec2.Location)
require.Equal(t, "i-0abc123", ec2.Name)
require.Equal(t, "vm", classifyCloudResourceType(ec2.NativeType))
s3, ok := awsARNToResource("arn:aws:s3:::my-bucket")
require.True(t, ok)
require.Equal(t, "my-bucket", s3.Name)
require.Equal(t, "blob", classifyCloudResourceType(s3.NativeType))
rds, ok := awsARNToResource("arn:aws:rds:eu-west-1:123:db:prod-pg")
require.True(t, ok)
require.Equal(t, "database", classifyCloudResourceType(rds.NativeType))
_, ok = awsARNToResource("not-an-arn")
require.False(t, ok)
}
// #5: SigV4 实现正确性 —— 对齐 AWS 官方 "get-vanilla" 测试向量。
func TestAWSSigV4_VanillaVector(t *testing.T) {
cred := awsDiscoveryCredentials{
AccessKeyID: "AKIDEXAMPLE",
SecretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
Region: "us-east-1",
}
tm := time.Date(2015, 8, 30, 12, 36, 0, 0, time.UTC)
auth, amzDate, err := awsSigV4Authorization("GET", "https://example.amazonaws.com/", map[string]string{}, []byte(""), cred, "service", tm)
require.NoError(t, err)
require.Equal(t, "20150830T123600Z", amzDate)
require.Contains(t, auth, "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request")
require.Contains(t, auth, "SignedHeaders=host;x-amz-date")
// 官方向量期望签名
require.Contains(t, auth, "Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31")
}
// #5 复审 #3:GCP searchAllResources URL —— scope 的 slash 不得被 %2F 转义。
func TestGCPSearchAllResourcesURL(t *testing.T) {
u := gcpSearchAllResourcesURL("my-proj", "")
require.Equal(t, "https://cloudasset.googleapis.com/v1/projects/my-proj:searchAllResources?pageSize=500", u)
require.NotContains(t, u, "%2F", "scope slash 不应被转义")
u2 := gcpSearchAllResourcesURL("my-proj", "tok en/+")
require.Contains(t, u2, "&pageToken=tok+en%2F%2B")
}
// #5 复审 #1:发现出的资源**绝不继承**账号 secret_ref;元数据带 classified_type,不含账号凭据引用。
func TestApplyDiscoveredResourceFields_NoSecretInheritance(t *testing.T) {
account := model.ResourceBinding{
Id: 42, UserId: 7, ResourceType: "cloud_account", Provider: "aws",
SecretRef: "azkv://heicode-kv.vault.azure.net/secrets/aws-keys",
}
d := discoveredCloudResource{
ExternalId: "arn:aws:ec2:us-east-1:123:instance/i-1",
Name: "i-1", NativeType: "AWS::EC2::Instance", Location: "us-east-1",
Metadata: map[string]any{"region": "us-east-1"},
}
var r model.ResourceBinding
require.NoError(t, applyDiscoveredResourceFields(&r, account, "aws", d))
require.Equal(t, "", r.SecretRef, "发现出的子资源绝不继承账号 secret_ref")
require.Equal(t, "cloud_resource", r.ResourceType)
require.Equal(t, "aws", r.Provider)
require.Equal(t, 7, r.UserId)
require.NotContains(t, r.Metadata, "azkv://", "metadata 不得含账号凭据引用")
require.Contains(t, r.Metadata, "\"classified_type\":\"vm\"")
require.Contains(t, r.PermissionScope, "aws:read")
}
// #5: GCP SA JWT —— RS256 断言可被对应公钥验签,且 claims 正确。
func TestBuildGCPAssertion_RS256Roundtrip(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
der, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
pemStr := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
cred := gcpDiscoveryCredentials{
ClientEmail: "svc@proj.iam.gserviceaccount.com",
PrivateKey: pemStr,
ProjectID: "proj",
}
tm := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
jwt, err := buildGCPAssertion(cred, "https://www.googleapis.com/auth/cloud-platform", "https://oauth2.googleapis.com/token", tm)
require.NoError(t, err)
parts := strings.Split(jwt, ".")
require.Len(t, parts, 3)
// 验签:signingInput = header.payload
signingInput := parts[0] + "." + parts[1]
digest := sha256.Sum256([]byte(signingInput))
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
require.NoError(t, err)
require.NoError(t, rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], sig), "RS256 签名应可被公钥验证")
// header alg + claims
hdr, err := base64.RawURLEncoding.DecodeString(parts[0])
require.NoError(t, err)
require.Contains(t, string(hdr), "RS256")
claims, err := base64.RawURLEncoding.DecodeString(parts[1])
require.NoError(t, err)
require.Contains(t, string(claims), "svc@proj.iam.gserviceaccount.com")
require.Contains(t, string(claims), "cloud-platform")
}
// #62: EC2 DescribeInstances XML 解析 —— 含 Name 标签 / ownerId / nextToken;ARN 由 region+owner+id 组装。
func TestParseEC2Instances(t *testing.T) {
body := []byte(`<?xml version="1.0"?>
<DescribeInstancesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
<reservationSet>
<item>
<ownerId>123456789012</ownerId>
<instancesSet>
<item>
<instanceId>i-0abc123</instanceId>
<instanceType>t3.micro</instanceType>
<instanceState><name>running</name></instanceState>
<placement><availabilityZone>us-east-1a</availabilityZone></placement>
<tagSet>
<item><key>Name</key><value>web-1</value></item>
<item><key>env</key><value>prod</value></item>
</tagSet>
</item>
<item>
<instanceId>i-0def456</instanceId>
<instanceType>t3.small</instanceType>
</item>
</instancesSet>
</item>
</reservationSet>
<nextToken>NEXT==</nextToken>
</DescribeInstancesResponse>`)
out, next, err := parseEC2Instances(body, "us-east-1")
require.NoError(t, err)
require.Equal(t, "NEXT==", next)
require.Len(t, out, 2)
require.Equal(t, "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123", out[0].ExternalId)
require.Equal(t, "web-1", out[0].Name) // Name 标签优先
require.Equal(t, "AWS::EC2::Instance", out[0].NativeType)
require.Equal(t, "vm", classifyCloudResourceType(out[0].NativeType))
require.Equal(t, "i-0def456", out[1].Name) // 无 Name 标签回退 instanceId
}
// #62: RDS DescribeDBInstances XML 解析 —— ARN 直接取 DBInstanceArn;无 ARN 跳过;Marker 续页。
func TestParseRDSInstances(t *testing.T) {
body := []byte(`<DescribeDBInstancesResponse xmlns="http://rds.amazonaws.com/doc/2014-10-31/">
<DescribeDBInstancesResult>
<Marker>m2</Marker>
<DBInstances>
<DBInstance>
<DBInstanceArn>arn:aws:rds:eu-west-1:123456789012:db:prod-pg</DBInstanceArn>
<DBInstanceIdentifier>prod-pg</DBInstanceIdentifier>
<Engine>postgres</Engine>
<DBInstanceStatus>available</DBInstanceStatus>
</DBInstance>
<DBInstance>
<DBInstanceIdentifier>no-arn</DBInstanceIdentifier>
<Engine>mysql</Engine>
</DBInstance>
</DBInstances>
</DescribeDBInstancesResult>
</DescribeDBInstancesResponse>`)
out, marker, err := parseRDSInstances(body, "eu-west-1")
require.NoError(t, err)
require.Equal(t, "m2", marker)
require.Len(t, out, 1) // 无 ARN 的被跳过
require.Equal(t, "arn:aws:rds:eu-west-1:123456789012:db:prod-pg", out[0].ExternalId)
require.Equal(t, "prod-pg", out[0].Name)
require.Equal(t, "database", classifyCloudResourceType(out[0].NativeType))
}
// #62: S3 ListBuckets XML 解析 —— ARN 为 arn:aws:s3:::<name>,全局无 region。
func TestParseS3Buckets(t *testing.T) {
body := []byte(`<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Owner><ID>x</ID></Owner>
<Buckets>
<Bucket><Name>my-bucket</Name><CreationDate>2024-01-01T00:00:00Z</CreationDate></Bucket>
<Bucket><Name>logs-bucket</Name></Bucket>
</Buckets>
</ListAllMyBucketsResult>`)
out, err := parseS3Buckets(body)
require.NoError(t, err)
require.Len(t, out, 2)
require.Equal(t, "arn:aws:s3:::my-bucket", out[0].ExternalId)
require.Equal(t, "my-bucket", out[0].Name)
require.Equal(t, "blob", classifyCloudResourceType(out[0].NativeType))
require.Empty(t, out[0].Location)
}
// #62: Lambda ListFunctions JSON 解析 —— ARN 取 FunctionArn;NextMarker 续页。
func TestParseLambdaFunctions(t *testing.T) {
body := []byte(`{"Functions":[
{"FunctionArn":"arn:aws:lambda:us-east-1:123456789012:function:fn-a","FunctionName":"fn-a","Runtime":"go1.x"},
{"FunctionArn":"arn:aws:lambda:us-east-1:123456789012:function:fn-b","FunctionName":"fn-b","Runtime":"python3.12"}
],"NextMarker":"mk"}`)
out, next, err := parseLambdaFunctions(body, "us-east-1")
require.NoError(t, err)
require.Equal(t, "mk", next)
require.Len(t, out, 2)
require.Equal(t, "arn:aws:lambda:us-east-1:123456789012:function:fn-a", out[0].ExternalId)
require.Equal(t, "fn-a", out[0].Name)
require.Equal(t, "AWS::Lambda::Function", out[0].NativeType)
}
// #62: 合并去重 —— 先到先得(tagged 优先保留),空 ExternalId 丢弃,顺序稳定。
func TestMergeDedupeByExternalID(t *testing.T) {
tagged := []discoveredCloudResource{
{ExternalId: "arn:a", Name: "tagged-a", Metadata: map[string]any{"tags": map[string]any{"k": "v"}}},
{ExternalId: "arn:b", Name: "tagged-b"},
}
ec2 := []discoveredCloudResource{
{ExternalId: "arn:a", Name: "ec2-a"}, // 与 tagged 重复 → 保留 tagged
{ExternalId: "arn:c", Name: "ec2-c"}, // 未打标签的新资源
{ExternalId: "", Name: "empty"}, // 空 id 丢弃
}
merged := mergeDedupeByExternalID(tagged, ec2)
require.Len(t, merged, 3)
require.Equal(t, "tagged-a", merged[0].Name) // tagged 优先
require.NotNil(t, merged[0].Metadata["tags"]) // 保留更丰富的 tagged 元数据
require.Equal(t, "tagged-b", merged[1].Name)
require.Equal(t, "ec2-c", merged[2].Name) // 补齐未打标签资源
}
@@ -0,0 +1,236 @@
package controller
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// GCP resource discovery (#5). Pure-Go service-account flow (no GCP SDK):
// build an RS256-signed JWT assertion from the SA private key, exchange it for an
// OAuth2 access token, then call Cloud Asset Inventory searchAllResources (REST)
// to enumerate the project's resources. Normalizes assets to discoveredCloudResource.
//
// Creds (from cloud_account metadata/secret): client_email, private_key (PEM),
// project_id. token_uri optional (defaults to Google's).
type gcpCloudDiscoveryProvider struct{}
func (gcpCloudDiscoveryProvider) name() string { return "gcp" }
func (gcpCloudDiscoveryProvider) discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) {
if mapString(creds, "project_id") == "" && strings.TrimSpace(account.ExternalId) != "" {
creds["project_id"] = account.ExternalId
}
return discoverGCP(creds, time.Now(), &http.Client{Timeout: 20 * time.Second})
}
type gcpDiscoveryCredentials struct {
ClientEmail string
PrivateKey string // PEM
ProjectID string
TokenURI string
}
func (c gcpDiscoveryCredentials) validate() error {
if strings.TrimSpace(c.ClientEmail) == "" {
return errors.New("GCP client_email required")
}
if strings.TrimSpace(c.PrivateKey) == "" {
return errors.New("GCP private_key required")
}
if strings.TrimSpace(c.ProjectID) == "" {
return errors.New("GCP project_id required")
}
return nil
}
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// parseRSAPrivateKeyPEM parses a PEM private key (PKCS#8 or PKCS#1) into an RSA key.
func parseRSAPrivateKeyPEM(pemStr string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(strings.TrimSpace(pemStr)))
if block == nil {
return nil, errors.New("GCP private_key is not valid PEM")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("GCP private_key parse failed: %w", err)
}
rsaKey, ok := keyAny.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("GCP private_key is not an RSA key")
}
return rsaKey, nil
}
// buildGCPAssertion builds the RS256-signed JWT assertion for the OAuth2 token
// exchange. Pure & deterministic given t — unit-testable with a generated key.
func buildGCPAssertion(cred gcpDiscoveryCredentials, scope string, aud string, t time.Time) (string, error) {
key, err := parseRSAPrivateKeyPEM(cred.PrivateKey)
if err != nil {
return "", err
}
header, err := common.Marshal(map[string]any{"alg": "RS256", "typ": "JWT"})
if err != nil {
return "", err
}
iat := t.UTC().Unix()
claims, err := common.Marshal(map[string]any{
"iss": cred.ClientEmail,
"scope": scope,
"aud": aud,
"iat": iat,
"exp": iat + 3600,
})
if err != nil {
return "", err
}
signingInput := b64url(header) + "." + b64url(claims)
digest := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
return signingInput + "." + b64url(sig), nil
}
func gcpAccessToken(cred gcpDiscoveryCredentials, t time.Time, httpClient *http.Client) (string, error) {
tokenURI := strings.TrimSpace(cred.TokenURI)
if tokenURI == "" {
tokenURI = "https://oauth2.googleapis.com/token"
}
assertion, err := buildGCPAssertion(cred, "https://www.googleapis.com/auth/cloud-platform", tokenURI, t)
if err != nil {
return "", err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
req, err := http.NewRequest(http.MethodPost, tokenURI, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("GCP token exchange failed: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return "", fmt.Errorf("GCP token exchange failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var payload struct {
AccessToken string `json:"access_token"`
}
if err := common.Unmarshal(raw, &payload); err != nil {
return "", err
}
if strings.TrimSpace(payload.AccessToken) == "" {
return "", errors.New("GCP token response missing access_token")
}
return payload.AccessToken, nil
}
type gcpAsset struct {
Name string `json:"name"`
AssetType string `json:"assetType"`
DisplayName string `json:"displayName"`
Location string `json:"location"`
}
type gcpSearchResponse struct {
Results []gcpAsset `json:"results"`
NextPageToken string `json:"nextPageToken"`
}
// gcpSearchAllResourcesURL 构造 Cloud Asset Inventory searchAllResources 端点。
// #5 复审 #3:scope 里的 slash 是路径模板的一部分,**不能** PathEscape 成 %2F —— 正确形如
// /v1/projects/<id>:searchAllResources。projectID 仍按单段转义,pageToken 走 query 转义。
func gcpSearchAllResourcesURL(projectID, pageToken string) string {
endpoint := "https://cloudasset.googleapis.com/v1/projects/" + url.PathEscape(projectID) + ":searchAllResources?pageSize=500"
if strings.TrimSpace(pageToken) != "" {
endpoint += "&pageToken=" + url.QueryEscape(pageToken)
}
return endpoint
}
func discoverGCP(creds map[string]any, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
cred := gcpDiscoveryCredentials{
ClientEmail: mapString(creds, "client_email"),
PrivateKey: mapString(creds, "private_key"),
ProjectID: mapString(creds, "project_id"),
TokenURI: mapString(creds, "token_uri"),
}
if err := cred.validate(); err != nil {
return nil, err
}
token, err := gcpAccessToken(cred, now, httpClient)
if err != nil {
return nil, err
}
out := []discoveredCloudResource{}
pageToken := ""
for page := 0; page < 50; page++ {
endpoint := gcpSearchAllResourcesURL(cred.ProjectID, pageToken)
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("GCP searchAllResources failed: %w", err)
}
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("GCP searchAllResources failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var payload gcpSearchResponse
if err := common.Unmarshal(raw, &payload); err != nil {
return nil, err
}
for _, a := range payload.Results {
if strings.TrimSpace(a.Name) == "" {
continue
}
name := strings.TrimSpace(a.DisplayName)
if name == "" {
name = lastPathSegment(a.Name)
}
out = append(out, discoveredCloudResource{
ExternalId: a.Name,
Name: name,
NativeType: a.AssetType, // e.g. compute.googleapis.com/Instance
Location: a.Location,
BindingScope: "gcp:" + a.Name,
Metadata: map[string]any{"project_id": cred.ProjectID, "asset_type": a.AssetType},
})
}
pageToken = strings.TrimSpace(payload.NextPageToken)
if pageToken == "" {
break
}
}
return out, nil
}
@@ -0,0 +1,63 @@
package controller
import (
"sort"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
"github.com/heicode/manager/service"
)
// buildAvailableModelItems maps resolved model names to the client-safe catalog
// shape for #25: only model_id / display_name / default. It deliberately omits
// channelId / base_url / api_key_ref / provider_type / per-token price — those
// stay server-side (Manager). Pure (testable).
func buildAvailableModelItems(modelNames []string, defaultModel string) []gin.H {
items := make([]gin.H, 0, len(modelNames))
for _, name := range modelNames {
items = append(items, gin.H{
"model_id": name,
"display_name": name,
"default": name == defaultModel,
})
}
return items
}
// HeicodeAvailableModels: GET /api/heicode/available-models (issue #25).
//
// The single source of truth for the desktop client's model list: the models
// the LOGGED-IN user may use, resolved server-side from the user's usable groups
// → group-enabled models (same resolution as GetUserModels). Bound to the user's
// own group/subscription; channelId / base_url / api_key / provider type / price
// are NEVER exposed. Same auth as /api/heicode/self (UserOrV2DeviceAuth / device
// pairing). Client uses this as the only model source (no local presets).
func HeicodeAvailableModels(c *gin.Context) {
userID := c.GetInt("id")
if userID <= 0 {
common.ApiErrorMsg(c, "authentication required")
return
}
user, err := model.GetUserCache(userID)
if err != nil {
common.ApiError(c, err)
return
}
seen := map[string]bool{}
var names []string
for group := range service.GetUserUsableGroups(user.Group) {
for _, m := range model.GetGroupEnabledModels(group) {
if !seen[m] {
seen[m] = true
names = append(names, m)
}
}
}
sort.Strings(names)
common.ApiSuccess(c, gin.H{
"available_models": buildAvailableModelItems(names, defaultAgentModelID()),
})
}
@@ -0,0 +1,38 @@
package controller
import (
"testing"
"github.com/stretchr/testify/require"
)
// buildAvailableModelItems must emit only the client-safe fields (model_id /
// display_name / default), flag exactly the default model, and never leak
// server-side routing fields. (#25)
func TestBuildAvailableModelItems(t *testing.T) {
items := buildAvailableModelItems([]string{"heicode-pro", "heicode-lite"}, "heicode-pro")
require.Len(t, items, 2)
require.Equal(t, "heicode-pro", items[0]["model_id"])
require.Equal(t, "heicode-pro", items[0]["display_name"])
require.Equal(t, true, items[0]["default"])
require.Equal(t, "heicode-lite", items[1]["model_id"])
require.Equal(t, false, items[1]["default"])
// client-safe: no server-only fields leak
for _, it := range items {
for _, banned := range []string{"channelId", "channel_id", "base_url", "api_key", "api_key_ref", "provider_type", "price", "ratio"} {
_, present := it[banned]
require.False(t, present, "field %q must not be exposed to client", banned)
}
require.Len(t, it, 3) // exactly model_id, display_name, default
}
// no default in the list -> none flagged
none := buildAvailableModelItems([]string{"a", "b"}, "zzz")
require.Equal(t, false, none[0]["default"])
require.Equal(t, false, none[1]["default"])
require.Empty(t, buildAvailableModelItems(nil, "x"))
}
+31
View File
@@ -0,0 +1,31 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
)
// HeicodeConfig: GET /api/heicode/config — client runtime config (issue #24).
//
// Dedicated config endpoint (client's chosen delivery, #24 §1) so kill switches
// propagate within a session WITHOUT re-login: the client polls this and obeys
// the latest telemetry.enabled / endpoint. Unauthenticated, non-sensitive global
// config — same posture as /api/heicode/capabilities; a natural home for future
// client config (feature flags, model-list pointer, …).
//
// telemetry.enabled defaults FALSE — telemetry stays off (the ingest endpoint
// also answers 410) until the privacy policy discloses account-linkable device
// IDs and ops flips HEICODE_TELEMETRY_ENABLED=true.
func HeicodeConfig(c *gin.Context) {
common.ApiSuccess(c, gin.H{
"telemetry": gin.H{
"enabled": common.GetEnvOrDefaultBool("HEICODE_TELEMETRY_ENABLED", false),
"endpoint": "/api/heicode/telemetry/events",
"max_batch": common.GetEnvOrDefault("HEICODE_TELEMETRY_MAX_BATCH", telemetryMaxBatch),
"flush_interval_sec": common.GetEnvOrDefault("HEICODE_TELEMETRY_FLUSH_INTERVAL_SEC", 30),
// Server retention window (#32): events older than this are purged.
"retention_days": telemetryRetentionDays(),
},
})
}
+37
View File
@@ -0,0 +1,37 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// /api/heicode/config carries the telemetry kill-switch block (#24). enabled
// must reflect HEICODE_TELEMETRY_ENABLED and the endpoint must match the ingest
// route so the client polls the right place.
func TestHeicodeConfig_TelemetryBlock(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("HEICODE_TELEMETRY_ENABLED", "true")
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/heicode/config", nil)
HeicodeConfig(ctx)
require.Equal(t, http.StatusOK, rec.Code)
body := rec.Body.String()
require.Contains(t, body, `"telemetry"`)
require.Contains(t, body, `"endpoint":"/api/heicode/telemetry/events"`)
require.Contains(t, body, `"enabled":true`)
// default OFF when the flag is unset
t.Setenv("HEICODE_TELEMETRY_ENABLED", "")
rec2 := httptest.NewRecorder()
ctx2, _ := gin.CreateTestContext(rec2)
ctx2.Request = httptest.NewRequest(http.MethodGet, "/api/heicode/config", nil)
HeicodeConfig(ctx2)
require.Contains(t, rec2.Body.String(), `"enabled":false`)
}
+46 -11
View File
@@ -11,22 +11,44 @@ import (
"github.com/heicode/manager/setting/operation_setting"
)
// quotaToDisplayUnit converts a raw quota integer (DB unit) to the
// site-configured display unit (USD / CNY / tokens). Mirrors the
// conversion in billing.go so the desktop "balance pill" shows the
// same number the user sees in the web dashboard.
func quotaToDisplayUnit(raw int) float64 {
amount := float64(raw)
// quotaDisplayUnitLabel names the unit the raw quota is denominated in. Returns
// the canonical (UPPER-CASE) display-type constant — USD / CNY / TOKENS / CUSTOM
// — mirroring the web dashboard. The client uses this only to pick a currency
// symbol; the actual conversion is done server-side (display_quota). Returning the
// constants verbatim keeps the wire value byte-identical to what the client
// compares against, and covers CUSTOM (previously fell through to "USD", so a
// custom-currency site mislabelled its display_quota and the client showed "$").
func quotaDisplayUnitLabel() string {
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
return amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
return operation_setting.QuotaDisplayTypeCNY
case operation_setting.QuotaDisplayTypeTokens:
return amount
return operation_setting.QuotaDisplayTypeTokens
case operation_setting.QuotaDisplayTypeCustom:
return operation_setting.QuotaDisplayTypeCustom
default:
return amount / common.QuotaPerUnit
return operation_setting.QuotaDisplayTypeUSD
}
}
// displayQuota converts a raw quota counter to the value the user should SEE,
// for the current站点 display type — server-side so the client never has to
// special-case a mode. This is the contract of record (no hardcoded ×500000):
//
// USD : raw / quota_per_unit
// CNY : raw / quota_per_unit * usd_exchange_rate
// CUSTOM : raw / quota_per_unit * custom_currency_exchange_rate
// TOKENS : raw (no division)
//
// usd_exchange_rate is the USD→display-currency multiplier returned alongside,
// so clients that prefer raw can recompute the same value.
func displayQuota(raw int, label string, perUnit, rate float64) float64 {
if label == operation_setting.QuotaDisplayTypeTokens || perUnit <= 0 {
return float64(raw)
}
return float64(raw) / perUnit * rate
}
// GetHeicodeSelf serves GET /api/heicode/self.
//
// Mounted on TokenAuth so the cc-haha desktop client can read it with
@@ -60,6 +82,12 @@ func GetHeicodeSelf(c *gin.Context) {
return
}
// Conversion params + server-computed display values so every quota_display_type
// (USD / CNY / tokens / custom) renders correctly without a client-side hack.
label := quotaDisplayUnitLabel()
perUnit := float64(common.QuotaPerUnit)
rate := operation_setting.GetUsdToCurrencyRate(operation_setting.USDExchangeRate) // USD→1, CNY→7.3, tokens→1
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
@@ -68,8 +96,15 @@ func GetHeicodeSelf(c *gin.Context) {
"display_name": user.DisplayName,
"group": user.Group,
"role": user.Role,
"quota": quotaToDisplayUnit(user.Quota),
"used_quota": quotaToDisplayUnit(user.UsedQuota),
// raw counters (NewAPI internal units) + the params to convert them.
"quota": user.Quota,
"used_quota": user.UsedQuota,
"quota_per_unit": common.QuotaPerUnit,
"quota_display_type": label, // USD / CNY / TOKENS / CUSTOM (pick currency symbol only)
"usd_exchange_rate": rate, // USD→display-currency multiplier (1 for USD/tokens)
// ready-to-show values (server applies the display-type formula above).
"display_quota": displayQuota(user.Quota, label, perUnit, rate),
"display_used_quota": displayQuota(user.UsedQuota, label, perUnit, rate),
"request_count": user.RequestCount,
},
})
+55
View File
@@ -0,0 +1,55 @@
package controller
import (
"testing"
"github.com/heicode/manager/setting/operation_setting"
"github.com/stretchr/testify/require"
)
// displayQuota is the /api/heicode/self conversion contract: the server applies
// the display-type formula so the client never special-cases a mode. Pin all
// four modes (USD divides, CNY/CUSTOM scale, TOKENS passes through) and the
// divide-by-zero guard.
func TestDisplayQuota_AllModes(t *testing.T) {
const perUnit = 500000.0
cases := []struct {
name string
raw int
label string
rate float64
want float64
}{
{"USD divides by per-unit", 1_000_000, operation_setting.QuotaDisplayTypeUSD, 1, 2},
{"CNY multiplies by usd rate", 1_000_000, operation_setting.QuotaDisplayTypeCNY, 7.3, 14.6},
{"CUSTOM uses custom rate", 1_000_000, operation_setting.QuotaDisplayTypeCustom, 2.5, 5},
{"TOKENS returns raw, no division", 1_000_000, operation_setting.QuotaDisplayTypeTokens, 1, 1_000_000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.InDelta(t, tc.want, displayQuota(tc.raw, tc.label, perUnit, tc.rate), 1e-9)
})
}
// per_unit<=0 must never divide by zero — return raw unchanged.
require.Equal(t, float64(1_000_000), displayQuota(1_000_000, operation_setting.QuotaDisplayTypeUSD, 0, 1))
}
// quotaDisplayUnitLabel must echo the canonical UPPER-CASE constant for every
// mode. The CUSTOM case is the regression guard: it previously fell through to
// "USD", mislabelling a custom-currency site's display_quota.
func TestQuotaDisplayUnitLabel_CanonicalAndCustom(t *testing.T) {
gs := operation_setting.GetGeneralSetting()
orig := gs.QuotaDisplayType
t.Cleanup(func() { gs.QuotaDisplayType = orig })
for _, want := range []string{
operation_setting.QuotaDisplayTypeUSD,
operation_setting.QuotaDisplayTypeCNY,
operation_setting.QuotaDisplayTypeTokens,
operation_setting.QuotaDisplayTypeCustom,
} {
gs.QuotaDisplayType = want
require.Equal(t, want, quotaDisplayUnitLabel())
}
}
+212
View File
@@ -0,0 +1,212 @@
package controller
import (
"encoding/json"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// Client error-telemetry ingest (issue #24). Contract: winos#23
// docs/integration/telemetry-ingest-endpoint.md. Body is a TOP-LEVEL JSON array
// of events (NOT wrapped). Auth reuses device-pairing (UserOrV2DeviceAuth). It
// is diagnostic traffic: NEVER bills (separate table, no consume log / quota).
//
// Default-OFF: until the privacy policy discloses account-linkable device IDs
// and endpoint delivery is agreed, HEICODE_TELEMETRY_ENABLED stays false and the
// endpoint answers 410 (kill switch) so the client stops sending.
const (
telemetryMaxBatch = 20
telemetryMaxBodySize = 256 * 1024
headerDeviceID = "X-Heicode-Device-Id"
// Per-field hard cap after redaction (#32): a backstop so a single event
// can't park an unbounded blob in the telemetry store even within batch
// limits. stack_top / context are truncated past this many bytes.
telemetryMaxFieldBytes = 8 * 1024
)
// telemetryContextAllowedKeys whitelists the non-content diagnostic keys the
// client may attach to an event's `context` (#32). Anything else is dropped
// before persistence, so a client regression can't land arbitrary — possibly
// identifying — JSON (prompts, code, tokens, emails, full file paths, raw IPs)
// in the telemetry store. Keep in sync with the client telemetry contract
// (winos#23) and docs/integration/heicode-desktop-client-api.md. Additions must
// be reviewed against the "no identifying content" rule in issue #32.
var telemetryContextAllowedKeys = map[string]bool{
"route": true, // logical UI route, e.g. "chat" (no params)
"retryable": true, // bool
"phase": true, // lifecycle phase enum
"exit_code": true, // process exit code (int)
"duration_ms": true, // numeric timing
"attempt": true, // retry attempt count
}
// filterTelemetryContext keeps only whitelisted keys from the client-supplied
// context object, then redacts and size-caps the result (#32). Returns "" when
// the context is empty, unparseable, or has no allowed keys — telemetry is
// best-effort diagnostics, so dropping an unrecognized payload is preferable to
// storing arbitrary JSON.
func filterTelemetryContext(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var obj map[string]json.RawMessage
if err := common.Unmarshal(raw, &obj); err != nil {
return "" // not an object (or malformed) -> drop
}
filtered := make(map[string]json.RawMessage, len(obj))
for k, v := range obj {
if telemetryContextAllowedKeys[k] {
filtered[k] = v
}
}
if len(filtered) == 0 {
return ""
}
b, err := common.Marshal(filtered)
if err != nil {
return ""
}
return capTelemetryField(model.RedactText(string(b)))
}
// capTelemetryField truncates an already-redacted field to telemetryMaxFieldBytes.
func capTelemetryField(s string) string {
if len(s) <= telemetryMaxFieldBytes {
return s
}
return s[:telemetryMaxFieldBytes]
}
type telemetryEventIn struct {
ClientId string `json:"client_id"`
SchemaVersion int `json:"schema_version"`
AppVersion string `json:"app_version"`
Platform string `json:"platform"`
OsVersion string `json:"os_version"`
Arch string `json:"arch"`
Locale string `json:"locale"`
ErrorCategory string `json:"error_category"`
ErrorCode string `json:"error_code"`
ErrorMessageHash string `json:"error_message_hash"`
StackHash string `json:"stack_hash"`
StackTop []string `json:"stack_top"`
Context json.RawMessage `json:"context"`
Timestamp string `json:"timestamp"`
SessionSeq int `json:"session_seq"`
}
// parseTelemetryBatch unmarshals the top-level JSON array of events. Pure.
func parseTelemetryBatch(body []byte) ([]telemetryEventIn, error) {
var events []telemetryEventIn
if err := common.Unmarshal(body, &events); err != nil {
return nil, err
}
return events, nil
}
// toModel maps an ingested event to its row. Deliberately tolerant (maximize
// capture): unknown enums / sentinel app_version / missing fields are stored
// as-is; schema_version defaults to 1; stack_top/context are kept as JSON text.
func (e telemetryEventIn) toModel(userID int, deviceID string, now int64) model.TelemetryEvent {
clientID := strings.TrimSpace(e.ClientId)
if clientID == "" {
clientID = deviceID
}
sv := e.SchemaVersion
if sv <= 0 {
sv = 1
}
// Defense-in-depth (#24 review): the client already sanitizes, but we also
// redact stack_top / context server-side — strip sk-/Bearer/URL tokens/JSON
// secret fields before persistence, so a producer bug can't land plaintext
// secrets in the telemetry store.
stackTopJSON := ""
if len(e.StackTop) > 0 {
if b, err := common.Marshal(e.StackTop); err == nil {
stackTopJSON = capTelemetryField(model.RedactText(string(b)))
}
}
// #32: context is restricted to a key whitelist (drop arbitrary/identifying
// JSON), then redacted and size-capped.
ctxJSON := filterTelemetryContext(e.Context)
return model.TelemetryEvent{
ReceivedAt: now,
UserId: userID,
ClientId: clientID,
SchemaVersion: sv,
AppVersion: e.AppVersion,
Platform: e.Platform,
OsVersion: e.OsVersion,
Arch: e.Arch,
Locale: e.Locale,
ErrorCategory: e.ErrorCategory,
ErrorCode: e.ErrorCode,
ErrorMessageHash: e.ErrorMessageHash,
StackHash: e.StackHash,
StackTopJSON: stackTopJSON,
ContextJSON: ctxJSON,
EventTimestamp: e.Timestamp,
SessionSeq: e.SessionSeq,
}
}
// HeicodeTelemetryEvents: POST /api/heicode/telemetry/events (issue #24).
func HeicodeTelemetryEvents(c *gin.Context) {
// Kill switch / not-live gate (default OFF). 410 => client stops sending.
if !common.GetEnvOrDefaultBool("HEICODE_TELEMETRY_ENABLED", false) {
c.JSON(http.StatusGone, gin.H{"success": false, "message": "telemetry ingest disabled"})
return
}
userID := c.GetInt("id")
if userID <= 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "authentication required"})
return
}
deviceID := strings.TrimSpace(c.GetHeader(headerDeviceID))
if deviceID == "" {
// Telemetry is desktop-only (device-paired); reject session-only callers.
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "telemetry requires a paired device"})
return
}
body, _ := io.ReadAll(io.LimitReader(c.Request.Body, telemetryMaxBodySize+1))
if len(body) > telemetryMaxBodySize {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"success": false, "message": "telemetry batch too large"})
return
}
events, err := parseTelemetryBatch(body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid telemetry body (expect a JSON array of events)"})
return
}
if len(events) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "accepted": 0})
return
}
if len(events) > telemetryMaxBatch {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"success": false, "message": "telemetry batch exceeds 20 events"})
return
}
now := common.GetTimestamp()
rows := make([]model.TelemetryEvent, 0, len(events))
for _, e := range events {
// Anti-spoof: a present client_id must equal the verified paired device.
if cid := strings.TrimSpace(e.ClientId); cid != "" && cid != deviceID {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "client_id does not match paired device"})
return
}
rows = append(rows, e.toModel(userID, deviceID, now))
}
if err := model.InsertTelemetryEvents(rows); err != nil {
common.SysLog("telemetry ingest persist failed: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "telemetry persist failed"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "accepted": len(rows)})
}
@@ -0,0 +1,109 @@
package controller
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// Uses the client's authoritative simulation batch (winos#23 §4) verbatim as the
// ingest fixture, so HM parsing/mapping stays in lock-step with what the client
// actually sends (#24).
const telemetryClientFixtureBatch = `[
{"client_id":"d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77","app_version":"0.5.0","platform":"win32","os_version":"10.0.26100","arch":"x64","locale":"zh-CN","error_category":"ui_crash","error_code":"RENDERER_ERROR","error_message_hash":"9f2a7c1b4e8d","stack_hash":"a1b2c3d4e5f6","stack_top":["at MessageList (MessageList.tsx:212:9)","at renderWithHooks (react-dom.production.min.js:0:0)"],"context":{"route":"chat"},"timestamp":"2026-06-09T07:21:33.123Z","session_seq":1},
{"client_id":"d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77","app_version":"0.0.0-heicode-local","platform":"win32","os_version":"10.0.26100","arch":"x64","locale":"zh-CN","error_category":"cli_startup_failed","error_code":"CLI_STARTUP_TIMEOUT","error_message_hash":"5c8e1f0a9b2d","stack_hash":"000000000000","stack_top":[],"context":{"retryable":true},"timestamp":"2026-06-09T07:22:01.880Z","session_seq":2}
]`
func TestParseTelemetryBatch_ClientFixture(t *testing.T) {
events, err := parseTelemetryBatch([]byte(telemetryClientFixtureBatch))
require.NoError(t, err)
require.Len(t, events, 2)
require.Equal(t, "d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77", events[0].ClientId)
require.Equal(t, "ui_crash", events[0].ErrorCategory)
require.Equal(t, "9f2a7c1b4e8d", events[0].ErrorMessageHash)
require.Len(t, events[0].StackTop, 2)
require.Equal(t, 1, events[0].SessionSeq)
// sentinel app_version is parsed as-is (must be tolerated on ingest)
require.Equal(t, "0.0.0-heicode-local", events[1].AppVersion)
}
func TestTelemetryToModel_MappingAndDefaults(t *testing.T) {
events, err := parseTelemetryBatch([]byte(telemetryClientFixtureBatch))
require.NoError(t, err)
const uid, dev, now = 4242, "d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77", int64(1700000000)
m0 := events[0].toModel(uid, dev, now)
require.Equal(t, uid, m0.UserId) // device -> account link recorded
require.Equal(t, dev, m0.ClientId)
require.EqualValues(t, now, m0.ReceivedAt) // server time, not client
require.Equal(t, "ui_crash", m0.ErrorCategory)
require.Equal(t, 1, m0.SchemaVersion) // missing schema_version defaults to 1
require.JSONEq(t, `{"route":"chat"}`, m0.ContextJSON)
require.JSONEq(t, `["at MessageList (MessageList.tsx:212:9)","at renderWithHooks (react-dom.production.min.js:0:0)"]`, m0.StackTopJSON)
require.Equal(t, "2026-06-09T07:21:33.123Z", m0.EventTimestamp)
// empty client_id falls back to the verified device id
ev := telemetryEventIn{ClientId: "", SchemaVersion: 0}
m := ev.toModel(uid, dev, now)
require.Equal(t, dev, m.ClientId)
require.Equal(t, 1, m.SchemaVersion)
require.Equal(t, "", m.StackTopJSON)
require.Equal(t, "", m.ContextJSON)
}
func TestParseTelemetryBatch_RejectsNonArray(t *testing.T) {
_, err := parseTelemetryBatch([]byte(`{"events":[]}`)) // wrapped object, not the contract
require.Error(t, err)
}
// Defense-in-depth (#24 review §3): even if the client failed to sanitize, the
// server must strip plaintext secrets from stack_top / context before storing.
func TestTelemetryToModel_RedactsSecrets(t *testing.T) {
ev := telemetryEventIn{
ClientId: "dev-1",
StackTop: []string{"at boom (auth.ts) key=sk-abcDEF1234567890"},
// "route" is whitelisted (#32) so it survives the field filter; redaction
// must still strip the Bearer token carried inside an allowed key.
Context: json.RawMessage(`{"route":"Authorization: Bearer aZ09tokenVALUE","retryable":true}`),
}
m := ev.toModel(7, "dev-1", 1700000000)
require.NotContains(t, m.StackTopJSON, "sk-abcDEF1234567890", "sk- secret must be redacted in stack_top")
require.Contains(t, m.StackTopJSON, "REDACTED")
require.NotContains(t, m.ContextJSON, "aZ09tokenVALUE", "Bearer token must be redacted in context")
require.Contains(t, m.ContextJSON, "retryable") // non-secret whitelisted content preserved
}
// #32: context must be restricted to a key whitelist so a client regression
// cannot land arbitrary/identifying JSON in the telemetry store.
func TestFilterTelemetryContext_Whitelist(t *testing.T) {
// allowed keys kept, unknown keys (incl. potentially identifying) dropped
out := filterTelemetryContext(json.RawMessage(
`{"route":"chat","retryable":true,"email":"a@b.com","file":"C:/Users/x/secret.go","prompt":"hi"}`))
require.Contains(t, out, "route")
require.Contains(t, out, "retryable")
require.NotContains(t, out, "email")
require.NotContains(t, out, "a@b.com")
require.NotContains(t, out, "secret.go")
require.NotContains(t, out, "prompt")
// no allowed keys -> dropped entirely
require.Equal(t, "", filterTelemetryContext(json.RawMessage(`{"email":"a@b.com"}`)))
// non-object / malformed -> dropped
require.Equal(t, "", filterTelemetryContext(json.RawMessage(`"a string"`)))
require.Equal(t, "", filterTelemetryContext(json.RawMessage(`not json`)))
require.Equal(t, "", filterTelemetryContext(nil))
}
// #32: per-field size cap is a backstop against unbounded blobs.
func TestCapTelemetryField(t *testing.T) {
require.Equal(t, "short", capTelemetryField("short"))
big := make([]byte, telemetryMaxFieldBytes+100)
for i := range big {
big[i] = 'a'
}
require.Len(t, capTelemetryField(string(big)), telemetryMaxFieldBytes)
}
+13 -2
View File
@@ -627,12 +627,23 @@ func UpsertResourceSecret(c *gin.Context) {
common.ApiError(c, err)
return
}
secretName := resourceSecretName(resource)
secretRef, err := client.putSecret(secretName, payload.Data)
// Issue #4 rotation: if this binding already has a secret, rotate it (Azure
// stores a new version under the same ref/name); otherwise create it. Either
// way the plaintext credential only lives in Key Vault, never in our DB.
var secretRef string
rotated := strings.TrimSpace(resource.SecretRef) != ""
if rotated {
secretRef, err = client.rotateSecret(resource.SecretRef, payload.Data)
} else {
secretRef, err = client.putSecret(resourceSecretName(resource), payload.Data)
}
if err != nil {
common.ApiError(c, err)
return
}
if rotated {
common.SysLog(fmt.Sprintf("UpsertResourceSecret: rotated KV secret for binding %d (user %d) — new version", resource.Id, userId))
}
resource.SecretRef = secretRef
if err := model.DB.Save(&resource).Error; err != nil {
common.ApiError(c, err)
+287
View File
@@ -0,0 +1,287 @@
package controller
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// Secret lifecycle (issue #4). Beyond the revoke-time delete already shipped in
// DeleteResource, this file closes the remaining lifecycle:
// 1. revokeUserResourceSecrets — on account disable/delete, soft-delete ALL of
// the user's resource-bound Key Vault secrets (full cleanup, not just DB).
// 2. StartSecretPurgeTask — daily job that PERMANENTLY purges vault secrets
// soft-deleted ≥ retention (default 30 days): the "30-day delete / render
// unrecoverable" requirement.
// 3. rotation — UpsertResourceSecret re-PUTs a credential, which Azure stores
// as a new version (rotateSecret == putSecret); see resource.go.
// revokeUserResourceSecrets revokes every still-active resource binding owned by
// userID and best-effort soft-deletes its Key Vault material, so disabling or
// deleting an account never leaves live credentials in the vault. The DB revoke
// is authoritative and committed first; a Key Vault outage is logged but never
// blocks account management. Returns the number of secrets soft-deleted.
func revokeUserResourceSecrets(userID int) int {
if userID <= 0 || model.DB == nil {
return 0
}
var bindings []model.ResourceBinding
if err := model.DB.
Where("user_id = ? AND secret_ref <> '' AND status <> ?", userID, "revoked").
Find(&bindings).Error; err != nil {
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: list bindings for user %d failed: %s", userID, err.Error()))
return 0
}
if len(bindings) == 0 {
return 0
}
ids := make([]int, 0, len(bindings))
for _, b := range bindings {
ids = append(ids, b.Id)
}
if err := model.DB.Model(&model.ResourceBinding{}).Where("id IN ?", ids).
Update("status", "revoked").Error; err != nil {
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: mark revoked for user %d failed: %s", userID, err.Error()))
}
store, sErr := newSecretStoreClientFromEnv()
if sErr != nil {
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: secret store unavailable, %d KV secrets NOT deleted for user %d: %s", len(bindings), userID, sErr.Error()))
return 0
}
deleted := 0
for _, b := range bindings {
if dErr := store.deleteSecret(b.SecretRef); dErr != nil {
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: KV delete failed for binding %d (user %d): %s", b.Id, userID, dErr.Error()))
continue
}
deleted++
}
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: user %d — %d bindings revoked, %d KV secrets soft-deleted", userID, len(bindings), deleted))
return deleted
}
// ── 30-day purge ────────────────────────────────────────────────────────────
type deletedSecretInfo struct {
name string
deletedDate int64 // unix seconds, from Azure KV
}
// parseDeletedSecretsPage parses one page of Azure KV GET /deletedsecrets and
// returns the secrets + the nextLink (empty when no more pages). Pure (testable).
func parseDeletedSecretsPage(body []byte) ([]deletedSecretInfo, string, error) {
var page struct {
Value []struct {
RecoveryID string `json:"recoveryId"`
ID string `json:"id"`
DeletedDate int64 `json:"deletedDate"`
} `json:"value"`
NextLink string `json:"nextLink"`
}
if err := common.Unmarshal(body, &page); err != nil {
return nil, "", err
}
items := make([]deletedSecretInfo, 0, len(page.Value))
for _, v := range page.Value {
name := lastPathSegment(v.RecoveryID)
if name == "" {
name = lastPathSegment(v.ID)
}
if name == "" {
continue
}
items = append(items, deletedSecretInfo{name: name, deletedDate: v.DeletedDate})
}
return items, strings.TrimSpace(page.NextLink), nil
}
func lastPathSegment(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if u, err := url.Parse(raw); err == nil && u.Path != "" {
raw = u.Path
}
raw = strings.Trim(raw, "/")
if i := strings.LastIndex(raw, "/"); i >= 0 {
raw = raw[i+1:]
}
return raw
}
// secretExpired reports whether a secret soft-deleted at deletedDate (unix sec)
// is at or past retentionDays old relative to nowUnix. Pure (testable).
func secretExpired(deletedDate int64, retentionDays int, nowUnix int64) bool {
if deletedDate <= 0 || retentionDays <= 0 {
return false
}
return nowUnix-deletedDate >= int64(retentionDays)*86400
}
// listDeletedSecrets returns every soft-deleted secret in the vault (paginated).
func (s secretStoreClient) listDeletedSecrets() ([]deletedSecretInfo, error) {
token, err := s.accessToken()
if err != nil {
return nil, err
}
endpoint := fmt.Sprintf("%s/deletedsecrets?api-version=7.4", s.vaultURL)
var out []deletedSecretInfo
for endpoint != "" {
req, rErr := http.NewRequest(http.MethodGet, endpoint, nil)
if rErr != nil {
return nil, rErr
}
req.Header.Set("Authorization", "Bearer "+token)
resp, dErr := s.client.Do(req)
if dErr != nil {
return nil, dErr
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
message := readSecretStoreError(bytes.NewReader(body))
if message == "" {
message = resp.Status
}
return nil, fmt.Errorf("Azure Key Vault list deleted secrets failed: %s", message)
}
items, next, pErr := parseDeletedSecretsPage(body)
if pErr != nil {
return nil, pErr
}
out = append(out, items...)
endpoint = next
}
return out, nil
}
// purgeExpiredVaultSecrets permanently purges every soft-deleted vault secret
// older than retentionDays. An optional namePrefix restricts purging to secrets
// HM manages (empty = all soft-deleted secrets in the vault). Returns (purged,
// scanned). Purge-protected vaults safely no-op (purgeSecret treats 403 as ok).
func purgeExpiredVaultSecrets(retentionDays int, namePrefix string, nowUnix int64) (purged int, scanned int, err error) {
store, err := newSecretStoreClientFromEnv()
if err != nil {
return 0, 0, err
}
items, err := store.listDeletedSecrets()
if err != nil {
return 0, 0, err
}
prefix := strings.TrimSpace(namePrefix)
for _, it := range items {
if prefix != "" && !strings.HasPrefix(it.name, prefix) {
continue
}
scanned++
if !secretExpired(it.deletedDate, retentionDays, nowUnix) {
continue
}
if pErr := store.purgeSecret(store.secretRef(it.name)); pErr != nil {
common.SysLog(fmt.Sprintf("purgeExpiredVaultSecrets: purge %q failed: %s", it.name, pErr.Error()))
continue
}
common.SysLog(fmt.Sprintf("purgeExpiredVaultSecrets: purged %q (soft-deleted %d days ago)", it.name, (nowUnix-it.deletedDate)/86400))
purged++
}
return purged, scanned, nil
}
var secretPurgeTaskOnce sync.Once
// StartSecretPurgeTask launches the daily background purge of vault secrets that
// have been soft-deleted ≥ HEICODE_SECRET_PURGE_RETENTION_DAYS (default 30) ago,
// satisfying issue #4's "render unrecoverable within 30 days" requirement.
// Disabled by HEICODE_SECRET_PURGE_ENABLED=false; no-ops when no vault is
// configured. HEICODE_SECRET_PURGE_NAME_PREFIX scopes purging to HM-managed
// secrets when the vault is shared.
func StartSecretPurgeTask() {
secretPurgeTaskOnce.Do(func() {
if !common.GetEnvOrDefaultBool("HEICODE_SECRET_PURGE_ENABLED", true) {
common.SysLog("secret purge task disabled (HEICODE_SECRET_PURGE_ENABLED=false)")
return
}
if strings.TrimSpace(os.Getenv("AZURE_KEY_VAULT_URL")) == "" {
return // no vault configured — nothing to purge
}
// #33: a destructive purge that scans the WHOLE vault is only safe when
// HM owns the vault exclusively. If no name prefix scopes purging to
// HM-managed secrets, require an explicit opt-in (HEICODE_SECRET_PURGE_
// VAULT_EXCLUSIVE=true) so HM never permanently purges another tenant's
// soft-deleted secrets that happen to live in a shared vault.
purgePrefix := strings.TrimSpace(common.GetEnvOrDefaultString("HEICODE_SECRET_PURGE_NAME_PREFIX", ""))
vaultExclusive := common.GetEnvOrDefaultBool("HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE", false)
allowed, scopeDesc := secretPurgeScopeAllowed(purgePrefix, vaultExclusive)
if !allowed {
common.SysLog("secret purge task NOT started: " + scopeDesc)
return
}
common.SysLog("secret purge scope: " + scopeDesc)
intervalHours := common.GetEnvOrDefault("HEICODE_SECRET_PURGE_INTERVAL_HOURS", 24)
if intervalHours < 1 {
intervalHours = 24
}
go func() {
time.Sleep(5 * time.Minute) // avoid startup churn
runSecretPurgeOnce()
ticker := time.NewTicker(time.Duration(intervalHours) * time.Hour)
defer ticker.Stop()
for range ticker.C {
runSecretPurgeOnce()
}
}()
common.SysLog(fmt.Sprintf("secret purge task started: retention=%dd interval=%dh", secretPurgeRetentionDays(), intervalHours))
})
}
// secretPurgeScopeAllowed decides whether the destructive vault purge may run,
// given the configured name prefix and the vault-exclusive opt-in (#33). A purge
// that scans the WHOLE vault (empty prefix) is only safe when HM owns the vault
// exclusively, so it must be explicitly opted in. Returns the decision plus a
// human-readable scope/refusal description for the startup log. Pure helper for
// unit testing without touching process env.
func secretPurgeScopeAllowed(namePrefix string, vaultExclusive bool) (bool, string) {
prefix := strings.TrimSpace(namePrefix)
if prefix == "" {
if !vaultExclusive {
return false, "HEICODE_SECRET_PURGE_NAME_PREFIX is empty and HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE!=true — refusing to purge an entire (possibly shared) vault. Set a name prefix to scope to HM-managed secrets, or set HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true only if this vault is exclusive to HM."
}
return true, "ENTIRE vault (HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true, no name prefix)"
}
return true, "secrets with name prefix '" + prefix + "'"
}
func secretPurgeRetentionDays() int {
d := common.GetEnvOrDefault("HEICODE_SECRET_PURGE_RETENTION_DAYS", 30)
if d < 1 {
d = 30
}
return d
}
func runSecretPurgeOnce() {
defer func() {
if r := recover(); r != nil {
common.SysLog(fmt.Sprintf("secret purge task panic recovered: %v", r))
}
}()
prefix := common.GetEnvOrDefaultString("HEICODE_SECRET_PURGE_NAME_PREFIX", "")
purged, scanned, err := purgeExpiredVaultSecrets(secretPurgeRetentionDays(), prefix, time.Now().Unix())
if err != nil {
common.SysLog("secret purge task: " + err.Error())
return
}
if purged > 0 {
common.SysLog(fmt.Sprintf("secret purge task: purged %d of %d scanned deleted secrets past %d-day retention", purged, scanned, secretPurgeRetentionDays()))
}
}
@@ -0,0 +1,91 @@
package controller
import (
"testing"
"github.com/heicode/manager/model"
"github.com/stretchr/testify/require"
)
// #33: an unscoped (empty-prefix) purge must be refused unless the operator
// explicitly declares the vault exclusive to HM, so HM never permanently purges
// another tenant's soft-deleted secrets in a shared vault.
func TestSecretPurgeScopeAllowed(t *testing.T) {
allowed, desc := secretPurgeScopeAllowed("", false)
require.False(t, allowed)
require.Contains(t, desc, "refusing to purge an entire")
allowed, desc = secretPurgeScopeAllowed(" ", true)
require.True(t, allowed)
require.Contains(t, desc, "ENTIRE vault")
allowed, desc = secretPurgeScopeAllowed("heicode-", false)
require.True(t, allowed)
require.Contains(t, desc, "heicode-")
}
func TestParseDeletedSecretsPage(t *testing.T) {
body := []byte(`{
"value": [
{"recoveryId":"https://v.vault.azure.net/deletedsecrets/users-7-bindings-abc","id":"https://v.vault.azure.net/secrets/users-7-bindings-abc","deletedDate":1700000000},
{"id":"https://v.vault.azure.net/secrets/foo","deletedDate":1700001000}
],
"nextLink":"https://v.vault.azure.net/deletedsecrets?api-version=7.4&$skiptoken=xyz"
}`)
items, next, err := parseDeletedSecretsPage(body)
require.NoError(t, err)
require.Len(t, items, 2)
require.Equal(t, "users-7-bindings-abc", items[0].name) // from recoveryId
require.EqualValues(t, 1700000000, items[0].deletedDate)
require.Equal(t, "foo", items[1].name) // recoveryId absent -> falls back to id
require.EqualValues(t, 1700001000, items[1].deletedDate)
require.NotEmpty(t, next)
}
func TestSecretExpired(t *testing.T) {
const now int64 = 1_000_000_000
const day int64 = 86400
require.True(t, secretExpired(now-31*day, 30, now), "31d old past 30d retention")
require.True(t, secretExpired(now-30*day, 30, now), "exactly 30d is past retention")
require.False(t, secretExpired(now-29*day, 30, now), "29d old still within retention")
require.False(t, secretExpired(0, 30, now), "unknown deletedDate never expires")
require.False(t, secretExpired(now-100*day, 0, now), "retention<=0 disables purge")
}
func TestLastPathSegment(t *testing.T) {
require.Equal(t, "abc", lastPathSegment("https://v.vault.azure.net/deletedsecrets/abc"))
require.Equal(t, "x", lastPathSegment("https://v.vault.azure.net/secrets/x/"))
require.Equal(t, "plain", lastPathSegment("plain"))
require.Equal(t, "", lastPathSegment(""))
}
// With no Key Vault configured, revokeUserResourceSecrets must still flip the
// user's secret-bearing bindings to revoked (DB is authoritative) and leave
// other users / secret-less bindings untouched.
func TestRevokeUserResourceSecrets_MarksRevoked(t *testing.T) {
setupResourceControllerTestDB(t)
require.NoError(t, model.DB.AutoMigrate(&model.ResourceBinding{}))
t.Setenv("AZURE_KEY_VAULT_URL", "") // force secret store unconfigured -> KV skipped
mk := func(uid int, secretRef string) model.ResourceBinding {
b := model.ResourceBinding{UserId: uid, Name: "n", ResourceType: "git", SecretRef: secretRef, Status: "active"}
require.NoError(t, model.DB.Create(&b).Error)
return b
}
withSecretA := mk(7777, "azkv://v.vault.azure.net/secrets/users-7777-a")
withSecretB := mk(7777, "azkv://v.vault.azure.net/secrets/users-7777-b")
noSecret := mk(7777, "") // not selected (secret_ref empty) -> stays active
otherUser := mk(8888, "azkv://v.vault.azure.net/secrets/users-8888-a")
revokeUserResourceSecrets(7777)
get := func(id int) string {
var b model.ResourceBinding
require.NoError(t, model.DB.Where("id = ?", id).First(&b).Error)
return b.Status
}
require.Equal(t, "revoked", get(withSecretA.Id))
require.Equal(t, "revoked", get(withSecretB.Id))
require.Equal(t, "active", get(noSecret.Id), "secret-less binding must not be revoked")
require.Equal(t, "active", get(otherUser.Id), "another user's binding must be untouched")
}
+11 -1
View File
@@ -4,10 +4,10 @@ import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
"github.com/heicode/manager/setting/ratio_setting"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -140,6 +140,10 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
common.ApiErrorMsg(c, "购买上限不能为负数")
return
}
if req.Plan.MaxAgents < 0 {
common.ApiErrorMsg(c, "Agent 部署上限不能为负数")
return
}
if req.Plan.TotalAmount < 0 {
common.ApiErrorMsg(c, "总额度不能为负数")
return
@@ -203,6 +207,10 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
common.ApiErrorMsg(c, "购买上限不能为负数")
return
}
if req.Plan.MaxAgents < 0 {
common.ApiErrorMsg(c, "Agent 部署上限不能为负数")
return
}
if req.Plan.TotalAmount < 0 {
common.ApiErrorMsg(c, "总额度不能为负数")
return
@@ -235,6 +243,8 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
"stripe_price_id": req.Plan.StripePriceId,
"creem_product_id": req.Plan.CreemProductId,
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
"max_agents": req.Plan.MaxAgents,
"swarm_enabled": req.Plan.SwarmEnabled,
"total_amount": req.Plan.TotalAmount,
"upgrade_group": req.Plan.UpgradeGroup,
"quota_reset_period": req.Plan.QuotaResetPeriod,
+114
View File
@@ -0,0 +1,114 @@
package controller
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// #46/#28:input/result 代理端点的运行时路径遵循 PR#41 契约 …/{deployment_id}/{input,result},
// 默认沿用 create-path 基路径,可经 env 覆盖。
func TestAgentRuntimeSwarmSubPaths(t *testing.T) {
cfg := agentRuntimeConfig{CreatePath: "/api/agent/swarm/deployments"}
require.Equal(t, "/api/agent/swarm/deployments/rt-9/input", agentRuntimeSwarmInputPath(cfg, "rt-9"))
require.Equal(t, "/api/agent/swarm/deployments/rt-9/result", agentRuntimeSwarmResultPath(cfg, "rt-9"))
// 空 CreatePath -> 回退到 canonical 默认基路径。
require.Equal(t, "/api/agent/swarm/deployments/x/input", agentRuntimeSwarmInputPath(agentRuntimeConfig{}, "x"))
// env 覆盖。
t.Setenv("SWARM_RUNTIME_RESULT_PATH", "/api/swarms/{deployment_id}/result")
require.Equal(t, "/api/swarms/rt-9/result", agentRuntimeSwarmResultPath(cfg, "rt-9"))
}
// input 转发:命中契约路径/方法/鉴权,body 携带 instruction + manager_deployment_id,解析 envelope.data。
func TestCallSwarmRuntimeJSON_InputForwards(t *testing.T) {
var gotMethod, gotPath, gotAuth, gotCorr, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotCorr = r.Header.Get("X-Correlation-ID")
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"task_id":"task-append-1","status":"running"}}`))
}))
defer srv.Close()
cfg := agentRuntimeConfig{Enabled: true, BaseURL: srv.URL, Token: "svc", CreatePath: "/api/agent/swarm/deployments", Timeout: 5 * time.Second}
dep := model.AgentDeployment{DeploymentID: "dep-1", RuntimeDeploymentID: "rt-9", CorrelationID: "cor-3"}
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmInputPath(cfg, dep.RuntimeDeploymentID))
require.NoError(t, err)
payload, err := common.Marshal(gin.H{"instruction": "refine the report", "manager_deployment_id": dep.DeploymentID})
require.NoError(t, err)
data, status, err := callSwarmRuntimeJSON(context.Background(), cfg, http.MethodPost, endpoint, payload, dep)
require.NoError(t, err)
require.Equal(t, http.StatusOK, status)
require.Equal(t, "task-append-1", stringFromMap(data, "task_id"))
require.Equal(t, http.MethodPost, gotMethod)
require.Equal(t, "/api/agent/swarm/deployments/rt-9/input", gotPath)
require.Equal(t, "Bearer svc", gotAuth)
require.Equal(t, "cor-3", gotCorr)
require.Contains(t, gotBody, "refine the report")
require.Contains(t, gotBody, "dep-1")
}
// result 解析 + 透出脱敏:deliverable/artifacts 内若混入 secret_ref/token 等敏感键,视图须递归剔除。
func TestCallSwarmRuntimeJSON_ResultParsesAndSanitizes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/agent/swarm/deployments/rt-9/result", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"status":"completed","summary":"done","termination_reason":"completed","deliverable":{"text":"the answer","secret_ref":"azkv://heicode-kv/x"},"artifacts":[{"uri":"git://repo/a.patch","token":"leak"}]}}`))
}))
defer srv.Close()
cfg := agentRuntimeConfig{BaseURL: srv.URL, Token: "svc", CreatePath: "/api/agent/swarm/deployments", Timeout: 5 * time.Second}
dep := model.AgentDeployment{DeploymentID: "dep-1", RuntimeDeploymentID: "rt-9"}
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmResultPath(cfg, dep.RuntimeDeploymentID))
require.NoError(t, err)
data, _, err := callSwarmRuntimeJSON(context.Background(), cfg, http.MethodGet, endpoint, nil, dep)
require.NoError(t, err)
require.Equal(t, "completed", stringFromMap(data, "status"))
require.Equal(t, "done", stringFromMap(data, "summary"))
deliverable, ok := stripSensitiveKeys(data["deliverable"]).(map[string]any)
require.True(t, ok)
require.Equal(t, "the answer", deliverable["text"])
_, hasSecret := deliverable["secret_ref"]
require.False(t, hasSecret, "secret_ref 必须从 deliverable 剔除")
artifacts, ok := stripSensitiveKeys(data["artifacts"]).([]any)
require.True(t, ok)
require.Len(t, artifacts, 1)
first := artifacts[0].(map[string]any)
require.Equal(t, "git://repo/a.patch", first["uri"])
_, hasToken := first["token"]
require.False(t, hasToken, "token 必须从 artifact 剔除")
}
// 运行时非 2xx → 返回错误(handler 据此回 RUNTIME_UNAVAILABLE,不伪造受理)。
func TestCallSwarmRuntimeJSON_RuntimeError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`upstream down`))
}))
defer srv.Close()
cfg := agentRuntimeConfig{BaseURL: srv.URL, Token: "t", CreatePath: "/api/agent/swarm/deployments", Timeout: 5 * time.Second}
endpoint, _ := agentRuntimeURL(cfg.BaseURL, agentRuntimeSwarmResultPath(cfg, "rt"))
_, status, err := callSwarmRuntimeJSON(context.Background(), cfg, http.MethodGet, endpoint, nil, model.AgentDeployment{DeploymentID: "d", RuntimeDeploymentID: "rt"})
require.Error(t, err)
require.Equal(t, http.StatusBadGateway, status)
}
+112
View File
@@ -0,0 +1,112 @@
package controller
import (
"errors"
"fmt"
"strings"
"gorm.io/gorm"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// #60 模型 key 注入(方案A) —— 对接 agent_swarm PR#43 定死的参数:
//
// - A.1 粒度/命名:HM 为每个用户 mint 一把 per-user `sk-`,跨该用户所有 swarm run 复用;
// KV 密文名 = `swarm-model-key-<user_id>`。
// - A.2 KV value:JSON `{"openai_api_key":"sk-..."}`,对齐 Swarm
// `orchestrator/agent_launcher._extract_model_key` 的解析字段。
// - A.4 OPENAI_API_BASE:Swarm 部署常量(=HM 网关 /v1),HM **不**经 billing_context 下发。
// - A.5 吊销:事件驱动 —— 用户全部 run 被 stop 时 Swarm 恰好发一次 `swarm.pool_terminated`,
// HM 据此删 KV 密文 + 删 token(见 agent_callback.go handleSwarmPoolTerminated)。
//
// create 时把 putSecret 返回的 azkv:// secret_ref 放进 billing_context.secret_ref 下发;
// Swarm 侧凭 KV 读权限(A.3,运维授权 pending)解析后注入 agent 环境。
// 明文 `sk-` 仅经 secret_ref 服务端解析,绝不入代码/日志/事件/argv。
func swarmModelKeyTokenName(userID int) string {
return fmt.Sprintf("swarm:user:%d", userID)
}
func swarmModelKeySecretName(userID int) string {
return fmt.Sprintf("swarm-model-key-%d", userID)
}
// getOrMintSwarmModelToken 返回该用户长存的 swarm 模型 token 的 `sk-` bearer。
// 已存在(未软删)则复用(A.1:跨 run 复用、稳定 key),否则新建一把隐藏、无限额度、
// 不自然过期的系统托管 token(计费直接走 User.Quota,与 mintAgentModelToken 一致)。
func getOrMintSwarmModelToken(userID int) (string, error) {
if userID <= 0 || model.DB == nil {
return "", errors.New("invalid user for swarm model key")
}
name := swarmModelKeyTokenName(userID)
var tok model.Token
err := model.DB.Where("user_id = ? AND name = ?", userID, name).First(&tok).Error
if err == nil && strings.TrimSpace(tok.Key) != "" {
return "sk-" + tok.Key, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
rawKey, err := common.GenerateKey()
if err != nil {
return "", err
}
now := common.GetTimestamp()
tok = model.Token{
UserId: userID,
Name: name,
Key: rawKey,
Status: common.TokenStatusEnabled,
CreatedTime: now,
AccessedTime: now,
ExpiredTime: -1, // never naturally
UnlimitedQuota: true, // bills straight from User.Quota
HideFromUserUI: true, // 系统托管令牌,不在用户令牌列表展示
}
if err := tok.Insert(); err != nil {
return "", err
}
return "sk-" + rawKey, nil
}
// provisionSwarmModelKey 确保用户的 swarm 模型 key 已就位(token + KV 密文),返回 azkv:// secret_ref。
// 仅在 Azure Key Vault 已配置时可用;未配置/不可达返回 error,由调用方按需降级
// (联调前 secret_ref 可留空,不阻断 swarm create)。
func provisionSwarmModelKey(userID int) (string, error) {
store, err := newSecretStoreClientFromEnv()
if err != nil {
return "", err
}
bearer, err := getOrMintSwarmModelToken(userID)
if err != nil {
return "", err
}
// A.2:KV value = JSON {"openai_api_key":"sk-..."}。putSecret 幂等(同名新建版本)。
secretRef, err := store.putSecret(swarmModelKeySecretName(userID), map[string]any{
"openai_api_key": bearer,
})
if err != nil {
return "", err
}
return secretRef, nil
}
// revokeSwarmModelKey 吊销用户的 swarm 模型 key:删 KV 密文 + 软删 token(均尽力而为)。
// 由 swarm.pool_terminated 回调触发(A.5)。token 软删后,下次 swarm create 会重新 mint 一把新 key。
func revokeSwarmModelKey(userID int) {
if userID <= 0 {
return
}
if store, err := newSecretStoreClientFromEnv(); err == nil {
if err := store.deleteSecret(store.secretRef(swarmModelKeySecretName(userID))); err != nil {
common.SysLog("revokeSwarmModelKey delete KV secret: " + err.Error())
}
}
if model.DB != nil {
if err := model.DB.Where("user_id = ? AND name = ?", userID, swarmModelKeyTokenName(userID)).Delete(&model.Token{}).Error; err != nil {
common.SysLog("revokeSwarmModelKey delete token: " + err.Error())
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package controller
import (
"fmt"
"strings"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
func setupSwarmModelKeyTestDB(t *testing.T) {
t.Helper()
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false
common.RedisEnabled = false
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
model.DB = db
require.NoError(t, db.AutoMigrate(&model.Token{}))
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
model.DB = nil
})
}
func TestSwarmModelKeyNames(t *testing.T) {
require.Equal(t, "swarm:user:42", swarmModelKeyTokenName(42))
require.Equal(t, "swarm-model-key-42", swarmModelKeySecretName(42))
}
func TestGetOrMintSwarmModelTokenReusesPerUser(t *testing.T) {
setupSwarmModelKeyTestDB(t)
// A.1: first call mints, second call reuses the SAME sk- across runs.
first, err := getOrMintSwarmModelToken(7001)
require.NoError(t, err)
require.True(t, strings.HasPrefix(first, "sk-"))
second, err := getOrMintSwarmModelToken(7001)
require.NoError(t, err)
require.Equal(t, first, second, "per-user swarm key must be reused across runs")
// only one token persisted for the user, and it is hidden + system-managed.
var toks []model.Token
require.NoError(t, model.DB.Where("user_id = ?", 7001).Find(&toks).Error)
require.Len(t, toks, 1)
require.Equal(t, swarmModelKeyTokenName(7001), toks[0].Name)
require.True(t, toks[0].HideFromUserUI)
require.True(t, toks[0].UnlimitedQuota)
require.EqualValues(t, -1, toks[0].ExpiredTime)
// distinct users get distinct keys.
other, err := getOrMintSwarmModelToken(7002)
require.NoError(t, err)
require.NotEqual(t, first, other)
// invalid user rejected.
_, err = getOrMintSwarmModelToken(0)
require.Error(t, err)
}
func TestRevokeSwarmModelKeyReMintsFresh(t *testing.T) {
setupSwarmModelKeyTestDB(t)
first, err := getOrMintSwarmModelToken(7003)
require.NoError(t, err)
// revoke soft-deletes the token (KV not configured -> delete logged, non-fatal).
revokeSwarmModelKey(7003)
var live []model.Token
require.NoError(t, model.DB.Where("user_id = ?", 7003).Find(&live).Error)
require.Len(t, live, 0, "revoked swarm token must be soft-deleted")
// next provision mints a brand-new key (A.5: stop -> revoke -> new key on re-run).
second, err := getOrMintSwarmModelToken(7003)
require.NoError(t, err)
require.NotEqual(t, first, second)
}
func TestHandleSwarmPoolTerminatedRevokesByPayloadUserID(t *testing.T) {
setupSwarmModelKeyTestDB(t)
_, err := getOrMintSwarmModelToken(7004)
require.NoError(t, err)
payload := agentCallbackEnvelope{
EventType: "swarm.pool_terminated",
Payload: map[string]any{"user_id": "7004", "secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/swarm-model-key-7004"},
}
handleSwarmPoolTerminated(payload, agentDeploymentRecord{})
var live []model.Token
require.NoError(t, model.DB.Where("user_id = ?", 7004).Find(&live).Error)
require.Len(t, live, 0)
}
func TestHandleSwarmPoolTerminatedFallsBackToDeploymentUser(t *testing.T) {
setupSwarmModelKeyTestDB(t)
_, err := getOrMintSwarmModelToken(7005)
require.NoError(t, err)
// no user_id in payload -> fall back to deployment user context.
payload := agentCallbackEnvelope{EventType: "swarm.pool_terminated", Payload: map[string]any{}}
record := agentDeploymentRecord{}
record.Plan.UserContext.UserID = "7005"
handleSwarmPoolTerminated(payload, record)
var live []model.Token
require.NoError(t, model.DB.Where("user_id = ?", 7005).Find(&live).Error)
require.Len(t, live, 0)
}
func TestHandleSwarmPoolTerminatedIgnoresOtherEvents(t *testing.T) {
setupSwarmModelKeyTestDB(t)
_, err := getOrMintSwarmModelToken(7006)
require.NoError(t, err)
// wrong event type -> no revocation.
handleSwarmPoolTerminated(agentCallbackEnvelope{EventType: "swarm.stopped", Payload: map[string]any{"user_id": "7006"}}, agentDeploymentRecord{})
var live []model.Token
require.NoError(t, model.DB.Where("user_id = ?", 7006).Find(&live).Error)
require.Len(t, live, 1)
}
func TestSwarmPoolTerminatedEventRegistered(t *testing.T) {
// control-plane lifecycle event must be a known callback type so the schema
// validator accepts it and routes it to the swarm_lifecycle category.
_, hasFields := agentCallbackEventRequiredFields["swarm.pool_terminated"]
require.True(t, hasFields)
require.Equal(t, "swarm_lifecycle", agentCallbackEventCategories["swarm.pool_terminated"])
}
+77
View File
@@ -0,0 +1,77 @@
package controller
import (
"fmt"
"sync"
"time"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// Telemetry retention (#32). Account-linkable client error-telemetry must not be
// kept indefinitely: a daily task deletes rows older than the retention window.
// Disabled when telemetry ingest is off (default) or retention <= 0.
//
// - HEICODE_TELEMETRY_RETENTION_DAYS (default 30): rows received earlier than
// now-RETENTION are deleted. <=0 disables the purge (keep-forever — only for
// explicit operator opt-out; not recommended for production).
// - HEICODE_TELEMETRY_RETENTION_INTERVAL_HOURS (default 24): sweep cadence.
//
// Master-only (wired from main.go under IsMasterNode) so multiple nodes don't
// all sweep the shared LOG_DB.
var telemetryRetentionTaskOnce sync.Once
func telemetryRetentionDays() int {
return common.GetEnvOrDefault("HEICODE_TELEMETRY_RETENTION_DAYS", 30)
}
// StartTelemetryRetentionTask launches the daily telemetry retention sweep.
func StartTelemetryRetentionTask() {
telemetryRetentionTaskOnce.Do(func() {
// Only meaningful once ingest is enabled; if the endpoint is off there is
// nothing being written, but we still allow the sweep to drain any rows
// captured during a prior enabled window. Gate on retention days instead.
days := telemetryRetentionDays()
if days <= 0 {
common.SysLog("telemetry retention task disabled (HEICODE_TELEMETRY_RETENTION_DAYS<=0; rows kept indefinitely)")
return
}
intervalHours := common.GetEnvOrDefault("HEICODE_TELEMETRY_RETENTION_INTERVAL_HOURS", 24)
if intervalHours < 1 {
intervalHours = 24
}
go func() {
time.Sleep(5 * time.Minute) // avoid startup churn
runTelemetryRetentionOnce()
ticker := time.NewTicker(time.Duration(intervalHours) * time.Hour)
defer ticker.Stop()
for range ticker.C {
runTelemetryRetentionOnce()
}
}()
common.SysLog(fmt.Sprintf("telemetry retention task started: retention=%dd interval=%dh", days, intervalHours))
})
}
func runTelemetryRetentionOnce() {
defer func() {
if r := recover(); r != nil {
common.SysLog(fmt.Sprintf("telemetry retention task panic recovered: %v", r))
}
}()
days := telemetryRetentionDays()
if days <= 0 {
return
}
cutoff := time.Now().Unix() - int64(days)*86400
deleted, err := model.DeleteTelemetryEventsBefore(cutoff)
if err != nil {
common.SysLog("telemetry retention task: " + err.Error())
return
}
if deleted > 0 {
common.SysLog(fmt.Sprintf("telemetry retention task: deleted %d events older than %d days", deleted, days))
}
}
+15
View File
@@ -780,6 +780,9 @@ func DeleteUser(c *gin.Context) {
})
return
}
// Hard-deleting an account also revokes + deletes its resource-bound Key
// Vault credential material (issue #4). Best-effort.
revokeUserResourceSecrets(id)
}
func DeleteSelf(c *gin.Context) {
@@ -796,6 +799,9 @@ func DeleteSelf(c *gin.Context) {
common.ApiError(c, err)
return
}
// Self-deletion also revokes + deletes the account's resource-bound Key
// Vault credential material (issue #4). Best-effort.
revokeUserResourceSecrets(id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -898,6 +904,10 @@ func ManageUser(c *gin.Context) {
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
// Account deletion must also revoke + delete the user's resource-bound
// Key Vault credential material (issue #4): account gone, secrets gone.
// Best-effort; never blocks the delete.
revokeUserResourceSecrets(user.Id)
case "promote":
if myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
@@ -983,6 +993,11 @@ func ManageUser(c *gin.Context) {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
}
if req.Action == "disable" {
// Disabling an account also revokes its resource-bound KV secrets (issue
// #4), so a disabled user's live credentials don't linger in the vault.
revokeUserResourceSecrets(user.Id)
}
clearUser := model.User{
Role: user.Role,
Status: user.Status,
+4 -1
View File
@@ -61,7 +61,10 @@ services:
- AGENT_RUNTIME_STOP_PATH=${AGENT_RUNTIME_STOP_PATH:-/api/agent/deployments/{deployment_id}/stop}
- AGENT_RUNTIME_SERVICE_TOKEN=${AGENT_RUNTIME_SERVICE_TOKEN:-}
- AGENT_RUNTIME_CALLBACK_SIGNING_SECRET_REF=${AGENT_RUNTIME_CALLBACK_SIGNING_SECRET_REF:-}
# HeiCode-Swarm Runtime is separate from ordinary sub Agent Runtime.
# agent_swarm (product: HeiCode Swarm) Runtime — separate from the single
# template-agent Runtime (AM). Currently DEFERRED/disabled (SWARM_RUNTIME_ENABLED=false):
# HM does not orchestrate swarm; pending agent_swarm runtime-contract.md freeze (agent_swarm#2).
# See docs/integration/heicode-swarm-deferred.md.
- SWARM_RUNTIME_ENABLED=${SWARM_RUNTIME_ENABLED:-false}
- SWARM_RUNTIME_BASE_URL=${SWARM_RUNTIME_BASE_URL:-}
- SWARM_RUNTIME_CREATE_PATH=${SWARM_RUNTIME_CREATE_PATH:-/api/swarms}
+9
View File
@@ -130,6 +130,15 @@ func main() {
// Channel upstream model update check task
controller.StartChannelUpstreamModelUpdateTask()
// Secret lifecycle: daily purge of vault secrets soft-deleted past the
// retention window (issue #4). Master-only so multiple nodes don't all purge.
if common.IsMasterNode {
controller.StartSecretPurgeTask()
// Telemetry retention: daily purge of client error-telemetry older than
// HEICODE_TELEMETRY_RETENTION_DAYS (#32). Master-only.
controller.StartTelemetryRetentionTask()
}
if common.IsMasterNode && constant.UpdateTask {
gopool.Go(func() {
controller.UpdateMidjourneyTaskBulk()
+46
View File
@@ -1,6 +1,7 @@
package model
import (
"regexp"
"time"
"github.com/heicode/manager/common"
@@ -75,11 +76,56 @@ func InsertAgentAuditEvent(evt *AgentAuditEvent) {
if evt.Result == "" {
evt.Result = "ok"
}
// Defence-in-depth: scrub plaintext secrets out of the free-form payload
// before it lands in the audit table. DetailsJSON is the catch-all bucket
// (failure_reason / phase / callback_url …) and a callback_url can carry a
// query-string token; product policy forbids plaintext token/password/
// private-key/access-key in logs. Callers should still avoid putting secrets
// here — this is a backstop, not a licence to log them.
evt.DetailsJSON = redactAuditSecrets(evt.DetailsJSON)
if err := DB.Create(evt).Error; err != nil {
common.SysLog("InsertAgentAuditEvent: " + err.Error())
}
}
// secretRedactors strip well-known secret shapes from free-form audit payloads.
// Targeted (not a generic high-entropy scan) to avoid mangling normal text;
// over-redacting a rare false positive in audit details is preferable to
// leaking a credential.
var secretRedactors = []struct {
re *regexp.Regexp
repl string
}{
// sk- style API keys (NewAPI / OpenAI-compatible gateway keys)
{regexp.MustCompile(`sk-[A-Za-z0-9_-]{8,}`), "sk-***REDACTED***"},
// Authorization: Bearer <token>
{regexp.MustCompile(`(?i)(bearer\s+)[A-Za-z0-9._\-]{8,}`), "${1}***REDACTED***"},
// URL / form query secrets: ?token=.. &access_key=.. (keep the key, drop value)
{regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|token|api_?key|access_?key|secret|password|passwd|pwd|sig|signature)=)[^&\s"']+`), "${1}***REDACTED***"},
// JSON string fields: "password":"..", "token":"..", "access_key":".."
{regexp.MustCompile(`(?i)("(?:password|passwd|pwd|secret|api_?key|access_?key|private_?key|token|refresh_token)"\s*:\s*")[^"]*(")`), "${1}***REDACTED***${2}"},
}
// redactAuditSecrets removes plaintext secrets from a free-form audit payload
// before persistence. Returns the input unchanged when empty.
func redactAuditSecrets(s string) string {
if s == "" {
return s
}
for _, r := range secretRedactors {
s = r.re.ReplaceAllString(s, r.repl)
}
return s
}
// RedactText is an exported wrapper over the audit secret-redactor so other
// ingest paths (e.g. client telemetry #24) can defense-in-depth strip plaintext
// secrets (sk-/Bearer/URL tokens/JSON secret fields) before persistence, even
// when the producer claims the payload is already sanitized.
func RedactText(s string) string {
return redactAuditSecrets(s)
}
// ListAgentAuditEventsFilter narrows the audit query to a slice of
// dashboard relevant rows. Zero-value fields are ignored — callers
// pass only the filters they care about.
+92
View File
@@ -2,6 +2,7 @@ package model
import (
"fmt"
"strings"
"testing"
"time"
)
@@ -141,3 +142,94 @@ func TestListAgentAuditEventsByDeployment_Chronological(t *testing.T) {
t.Fatalf("expected [evt_early, evt_late] order, got %+v", rows)
}
}
// TestRedactAuditSecrets locks the audit-log redaction security boundary:
// plaintext secrets must never reach the audit table's free-form DetailsJSON.
// Covers every shape secretRedactors targets (sk-, Bearer, URL query token,
// JSON password/api_key/private_key) and guards against over-redacting normal
// text. This is a security boundary, so it ships as a persistent unit test
// rather than a one-off smoke check (see #9).
func TestRedactAuditSecrets(t *testing.T) {
const redacted = "***REDACTED***"
cases := []struct {
name string
in string
mustNotHave string // a secret value that must be gone after redaction
mustHave []string // substrings that must survive (keys kept, normal text intact)
}{
{
name: "sk- gateway key",
in: `{"failure_reason":"upstream rejected key sk-abcDEF1234567890"}`,
mustNotHave: "sk-abcDEF1234567890",
mustHave: []string{"sk-" + redacted, `"failure_reason"`},
},
{
name: "Authorization Bearer token",
in: `called callback with header Authorization: Bearer aZ09._-tokenValue123`,
mustNotHave: "aZ09._-tokenValue123",
mustHave: []string{redacted, "Authorization"},
},
{
name: "URL query token keeps key drops value",
in: `callback_url=https://hook.example.com/cb?token=supersecretval123&phase=deploy`,
mustNotHave: "supersecretval123",
mustHave: []string{"token=" + redacted, "phase=deploy"}, // non-secret param survives
},
{
name: "URL query access_key",
in: `https://x/y?access_key=AKIA1234567890abcd&region=eastus`,
mustNotHave: "AKIA1234567890abcd",
mustHave: []string{"access_key=" + redacted, "region=eastus"},
},
{
name: "JSON password field",
in: `{"db":"pg","password":"hunter2pass","host":"db.local"}`,
mustNotHave: "hunter2pass",
mustHave: []string{`"password":"` + redacted + `"`, `"host":"db.local"`},
},
{
name: "JSON api_key value",
in: `{"api_key":"k-LIVE-9988","note":"ok"}`,
mustNotHave: "k-LIVE-9988",
mustHave: []string{redacted, `"note":"ok"`},
},
{
name: "JSON private_key value",
in: `{"private_key":"MIIEvQIBADANBgkq"}`,
mustNotHave: "MIIEvQIBADANBgkq",
mustHave: []string{`"private_key":"` + redacted + `"`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := redactAuditSecrets(tc.in)
if tc.mustNotHave != "" && strings.Contains(got, tc.mustNotHave) {
t.Fatalf("secret leaked: %q still present in %q", tc.mustNotHave, got)
}
for _, want := range tc.mustHave {
if !strings.Contains(got, want) {
t.Fatalf("expected %q to survive, got %q", want, got)
}
}
})
}
// Guard: normal audit text with no secret shape must pass through unchanged —
// over-redaction would corrupt legitimate audit context.
for _, s := range []string{
"deploy failed: AM returned HTTP 500, pod not ready",
"phase=provisioning binding_scope=git-master status=running",
"agent dep_abc123 stopped by user",
} {
if got := redactAuditSecrets(s); got != s {
t.Fatalf("normal text over-redacted: %q -> %q", s, got)
}
}
// Empty input is returned unchanged.
if got := redactAuditSecrets(""); got != "" {
t.Fatalf("empty input changed to %q", got)
}
}
+42 -1
View File
@@ -1,6 +1,9 @@
package model
import "errors"
import (
"errors"
"strings"
)
type AgentCallbackEvent struct {
Id int `gorm:"primaryKey" json:"id"`
@@ -15,6 +18,9 @@ type AgentCallbackEvent struct {
UserID string `gorm:"type:varchar(64);index" json:"user_id"`
BindingScope string `gorm:"type:varchar(512);index" json:"binding_scope"`
CorrelationID string `gorm:"type:varchar(64);index" json:"correlation_id"`
// agent_swarm event-schema v1(#15):per-swarm 严格递增序号(每 swarm 从 1、无空洞)。
// 0 = envelope 未带(legacy / 非 swarm);对外作客户端续传/去重游标。
Sequence int `gorm:"index;default:0" json:"sequence"`
Source string `gorm:"type:varchar(64)" json:"source"`
Result string `gorm:"type:varchar(32)" json:"result"`
PayloadJSON string `gorm:"type:text" json:"payload_json"`
@@ -55,6 +61,41 @@ func InsertAgentCallbackEvent(row *AgentCallbackEvent) (bool, error) {
return true, nil
}
// ListSwarmCallbackEventsAfter returns persisted swarm runtime events for one run
// (matched by deployment_id OR swarm_id) with an id-based `after` cursor for
// incremental polling (#45 events?after). Ordered oldest-first so the client can
// append; the caller uses the last returned Id as the next `after`. Reading from
// HM-persisted callback rows means this needs no live Swarm call.
func ListSwarmCallbackEventsAfter(userID, deploymentID, swarmID string, afterID, limit int) ([]AgentCallbackEvent, error) {
if DB == nil {
return nil, nil
}
q := DB.Model(&AgentCallbackEvent{})
switch {
case deploymentID != "" && swarmID != "":
q = q.Where("deployment_id = ? OR swarm_id = ?", deploymentID, swarmID)
case deploymentID != "":
q = q.Where("deployment_id = ?", deploymentID)
case swarmID != "":
q = q.Where("swarm_id = ?", swarmID)
default:
return nil, nil
}
// 防跨用户泄漏:即使 runtime_swarm_id/deployment_id 碰撞或误写,也按 user_id 收口(#45 复审 #3)。
if strings.TrimSpace(userID) != "" {
q = q.Where("user_id = ?", userID)
}
if afterID > 0 {
q = q.Where("id > ?", afterID)
}
if limit <= 0 || limit > 1000 {
limit = 200
}
var items []AgentCallbackEvent
err := q.Order("id asc").Limit(limit).Find(&items).Error
return items, err
}
func ListAgentCallbackEvents(f ListAgentCallbackEventsFilter) ([]AgentCallbackEvent, error) {
if DB == nil {
return nil, nil
+50
View File
@@ -0,0 +1,50 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
// #45: events?after 游标 + user 作用域 —— 按 deployment_id/swarm_id 过滤,id>after 增量,oldest-first;
// 传入 userID 时按 user_id 收口(防跨用户泄漏,复审 #3)。
func TestListSwarmCallbackEventsAfter(t *testing.T) {
require.NoError(t, LOG_DB.Where("1 = 1").Delete(&AgentCallbackEvent{}).Error)
mk := func(eventID, uid, dep, swarm, etype string) {
_, err := InsertAgentCallbackEvent(&AgentCallbackEvent{
EventID: eventID, UserID: uid, DeploymentID: dep, SwarmID: swarm, EventType: etype,
})
require.NoError(t, err)
}
mk("e1", "7", "dep_A", "swarm_A", "deployment.status_changed")
mk("e2", "7", "dep_A", "swarm_A", "task.created")
mk("e3", "7", "dep_A", "swarm_A", "artifact.produced")
mk("e4", "7", "dep_B", "swarm_B", "task.created") // 另一个 run
mk("e5", "9", "dep_A", "swarm_A", "task.created") // 同 dep/swarm 但别的用户 → 不应泄漏给 user 7
// user 7 + dep_A:3 条(e5 属 user 9,被排除),oldest-first
all, err := ListSwarmCallbackEventsAfter("7", "dep_A", "swarm_A", 0, 100)
require.NoError(t, err)
require.Len(t, all, 3)
require.Equal(t, "e1", all[0].EventID)
for _, e := range all {
require.NotEqual(t, "e5", e.EventID, "不得返回别的用户的事件")
}
// 游标:after = 第一条 id → 其后 2 条
rest, err := ListSwarmCallbackEventsAfter("7", "dep_A", "swarm_A", all[0].Id, 100)
require.NoError(t, err)
require.Len(t, rest, 2)
require.Equal(t, "e2", rest[0].EventID)
// user 9 只看到自己的 e5
u9, err := ListSwarmCallbackEventsAfter("9", "dep_A", "swarm_A", 0, 100)
require.NoError(t, err)
require.Len(t, u9, 1)
require.Equal(t, "e5", u9[0].EventID)
// 空标识 → 空
none, err := ListSwarmCallbackEventsAfter("7", "", "", 0, 100)
require.NoError(t, err)
require.Empty(t, none)
}
+41
View File
@@ -0,0 +1,41 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
// SumAgentUsage rolls up a single agent token's consume logs (issue #9). logs is
// migrated by the package TestMain; LOG_DB == DB in tests.
func TestSumAgentUsage(t *testing.T) {
tok := "agent:dep_usage_test"
mk := func(uid int, tname string, typ, quota, pt, ct int) {
require.NoError(t, LOG_DB.Create(&Log{
UserId: uid, TokenName: tname, Type: typ,
Quota: quota, PromptTokens: pt, CompletionTokens: ct, CreatedAt: 1700000000,
}).Error)
}
mk(970001, tok, LogTypeConsume, 100, 10, 5)
mk(970001, tok, LogTypeConsume, 200, 20, 15)
mk(970001, "agent:other", LogTypeConsume, 999, 99, 99) // different token — excluded
mk(970001, tok, LogTypeManage, 500, 0, 0) // non-consume — excluded
mk(970002, tok, LogTypeConsume, 777, 7, 7) // different user — excluded by user filter
u, err := SumAgentUsage(970001, tok, 0, 0)
require.NoError(t, err)
require.EqualValues(t, 300, u.Quota)
require.EqualValues(t, 30, u.PromptTokens)
require.EqualValues(t, 20, u.CompletionTokens)
require.EqualValues(t, 2, u.CallCount)
// time window excludes the rows (all stamped at 1700000000)
windowed, err := SumAgentUsage(970001, tok, 1800000000, 1900000000)
require.NoError(t, err)
require.EqualValues(t, 0, windowed.CallCount)
// empty token name -> zero, no error
z, err := SumAgentUsage(970001, "", 0, 0)
require.NoError(t, err)
require.EqualValues(t, 0, z.CallCount)
}
+35
View File
@@ -432,6 +432,41 @@ type Stat struct {
Tpm int `json:"tpm"`
}
// AgentUsage is the per-deployed-agent usage rollup (issue #9): a template agent
// calls HM /v1/* with its own minted token named "agent:<deployment_id>", so its
// consumption is the sum of consume logs under that token name.
type AgentUsage struct {
Quota int64 `json:"quota"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
CallCount int64 `json:"call_count"`
}
// SumAgentUsage aggregates consume-log usage for a single agent token (and user,
// when userId>0) over an optional time window. COALESCE keeps SUM non-null on
// empty sets; COUNT/SUM are portable across SQLite/MySQL/PostgreSQL.
func SumAgentUsage(userId int, tokenName string, startTimestamp int64, endTimestamp int64) (AgentUsage, error) {
var u AgentUsage
if tokenName == "" {
return u, nil
}
tx := LOG_DB.Table("logs").
Select("COALESCE(SUM(quota),0) AS quota, COALESCE(SUM(prompt_tokens),0) AS prompt_tokens, COALESCE(SUM(completion_tokens),0) AS completion_tokens, COUNT(*) AS call_count").
Where("type = ?", LogTypeConsume).
Where("token_name = ?", tokenName)
if userId > 0 {
tx = tx.Where("user_id = ?", userId)
}
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
err := tx.Scan(&u).Error
return u, err
}
func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat, err error) {
tx := LOG_DB.Table("logs").Select("sum(quota) quota")
+2
View File
@@ -333,6 +333,8 @@ func migrateDB() error {
// in-process `agentEvents map` that was wiped on every container
// restart. See model/agent_audit.go for the rationale.
&AgentAuditEvent{},
&TelemetryEvent{},
&PreflightConfirmation{},
)
if err != nil {
return err
+70
View File
@@ -0,0 +1,70 @@
package model
import (
"sort"
"strconv"
"strings"
)
// PreflightConfirmation 持久化一次 preflight「已确认版本」记录(#41 复审 #1/#4)。
//
// 为什么需要它:仅靠「部署时重算 hash == 传入 version」无法证明这个 version 曾被 confirm ——
// GET /api/heicode/preflight 也会返回同一个 version,客户端可绕过 confirm 与审计直接拿 GET
// version 去部署。把 confirm 落成一条**强一致、可查询**的记录,部署时校验该记录确实存在且未过期,
// 才真正满足「启动接口校验已确认版本」。审计事件(agent_audit_events)作为附加审计流。
type PreflightConfirmation struct {
Id int `gorm:"primaryKey" json:"id"`
UserID int `gorm:"index" json:"user_id"`
TemplateID string `gorm:"type:varchar(64);index" json:"template_id"`
BindingKey string `gorm:"type:varchar(512);index" json:"binding_key"` // 归一化排序后的 binding ids,便于审计/排查
Version string `gorm:"type:varchar(64);index" json:"version"` // 防篡改摘要版本
CreatedAtMs int64 `gorm:"bigint;index" json:"created_at_ms"`
ExpiresAtMs int64 `gorm:"bigint;index" json:"expires_at_ms"` // 0 表示不过期
}
func (PreflightConfirmation) TableName() string { return "preflight_confirmations" }
// PreflightBindingKey 把绑定 id 归一化(去重/去非正/升序)后拼成稳定 key,confirm 与 deploy
// 用同一算法,保证同一组绑定得到同一 key。
func PreflightBindingKey(bindingIDs []int) string {
seen := map[int]bool{}
ids := make([]int, 0, len(bindingIDs))
for _, n := range bindingIDs {
if n > 0 && !seen[n] {
seen[n] = true
ids = append(ids, n)
}
}
sort.Ints(ids)
parts := make([]string, 0, len(ids))
for _, n := range ids {
parts = append(parts, strconv.Itoa(n))
}
return strings.Join(parts, ",")
}
// InsertPreflightConfirmation 持久化一条确认记录。
func InsertPreflightConfirmation(rec *PreflightConfirmation) error {
if DB == nil || rec == nil {
return nil
}
return DB.Create(rec).Error
}
// PreflightConfirmationExists 校验存在一条匹配的、未过期的确认记录(#41 部署侧强校验)。
// 匹配 user + template + version(version 已编码资源+模板安全面);nowMs 用于过期判断,便于单测。
func PreflightConfirmationExists(userID int, templateID, version string, nowMs int64) (bool, error) {
if DB == nil {
return false, nil
}
version = strings.TrimSpace(version)
if userID <= 0 || strings.TrimSpace(templateID) == "" || version == "" {
return false, nil
}
var count int64
err := DB.Model(&PreflightConfirmation{}).
Where("user_id = ? AND template_id = ? AND version = ?", userID, templateID, version).
Where("expires_at_ms = 0 OR expires_at_ms > ?", nowMs).
Count(&count).Error
return count > 0, err
}
@@ -0,0 +1,57 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
// #41 复审 #1/#4:确认记录强校验 —— 存在/版本不符/过期/跨用户。
func TestPreflightConfirmationExists(t *testing.T) {
require.NoError(t, DB.Where("1 = 1").Delete(&PreflightConfirmation{}).Error)
const now = int64(1_700_000_000_000)
require.NoError(t, InsertPreflightConfirmation(&PreflightConfirmation{
UserID: 7, TemplateID: "architect", BindingKey: "1,2", Version: "pfv1_abc",
CreatedAtMs: now, ExpiresAtMs: now + 3600_000,
}))
// 命中
ok, err := PreflightConfirmationExists(7, "architect", "pfv1_abc", now+1000)
require.NoError(t, err)
require.True(t, ok)
// 版本不符 → 不命中(防止拿别的 version 部署)
ok, _ = PreflightConfirmationExists(7, "architect", "pfv1_OTHER", now+1000)
require.False(t, ok)
// 跨用户 → 不命中
ok, _ = PreflightConfirmationExists(9, "architect", "pfv1_abc", now+1000)
require.False(t, ok)
// 过期 → 不命中
ok, _ = PreflightConfirmationExists(7, "architect", "pfv1_abc", now+7200_000)
require.False(t, ok)
// 空参数 → 不命中
ok, _ = PreflightConfirmationExists(0, "architect", "pfv1_abc", now)
require.False(t, ok)
ok, _ = PreflightConfirmationExists(7, "architect", "", now)
require.False(t, ok)
}
// 不过期记录(ExpiresAtMs=0)恒命中。
func TestPreflightConfirmationExists_NoExpiry(t *testing.T) {
require.NoError(t, DB.Where("1 = 1").Delete(&PreflightConfirmation{}).Error)
require.NoError(t, InsertPreflightConfirmation(&PreflightConfirmation{
UserID: 5, TemplateID: "t", Version: "v", CreatedAtMs: 1, ExpiresAtMs: 0,
}))
ok, err := PreflightConfirmationExists(5, "t", "v", 9_999_999_999_999)
require.NoError(t, err)
require.True(t, ok)
}
func TestPreflightBindingKey_Normalizes(t *testing.T) {
require.Equal(t, "1,2,3", PreflightBindingKey([]int{3, 1, 2, 3, 0, -1, 1}))
require.Equal(t, "", PreflightBindingKey(nil))
}
+68
View File
@@ -165,6 +165,18 @@ type SubscriptionPlan struct {
// Max purchases per user (0 = unlimited)
MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"`
// Max concurrently-deployed template agents for users on this plan
// (0 = fall back to the global HEICODE_MAX_AGENTS_PER_USER default).
// Enforced in HeicodeDeployAgent. This is how 个人 5 / 团队 8 (#8) is
// expressed: admins set each plan's cap; the code does not hard-code tiers.
MaxAgents int `json:"max_agents" gorm:"type:int;default:0"`
// Whether users on this plan may use the multi-agent swarm (蜂群).
// Default false: swarm access is opt-in per plan; admins toggle it in the
// plan editor. Enforced in AgentCreateUserSwarm via GetUserSwarmEnabled.
// No hard-coded tiers — admins decide which plans get swarm.
SwarmEnabled bool `json:"swarm_enabled" gorm:"default:false"`
// Upgrade user group after purchase (empty = no change)
UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
@@ -696,6 +708,62 @@ func HasActiveUserSubscription(userId int) (bool, error) {
return count > 0, nil
}
// GetUserMaxAgents returns the user's effective concurrent-agent cap: the highest
// MaxAgents among their active subscription plans, or defaultMax when no active
// plan sets one. This is the tier-aware enforcement for #8 (个人默认 / 团队档),
// keyed off admin-configured per-plan caps rather than a hard-coded tier guess.
func GetUserMaxAgents(userId int, defaultMax int) int {
if userId <= 0 || DB == nil {
return defaultMax
}
now := common.GetTimestamp()
var subs []UserSubscription
if err := DB.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now).
Find(&subs).Error; err != nil || len(subs) == 0 {
return defaultMax
}
best := 0
for _, s := range subs {
plan, err := GetSubscriptionPlanById(s.PlanId)
if err != nil || plan == nil {
continue
}
if plan.MaxAgents > best {
best = plan.MaxAgents
}
}
if best > 0 {
return best
}
return defaultMax
}
// GetUserSwarmEnabled reports whether the user has any active subscription plan
// with SwarmEnabled=true. Mirrors GetUserMaxAgents (#8, tier-aware): no
// hard-coded tiers — swarm access is per-plan, set by admins. No active plan /
// DB unavailable => false (swarm is opt-in per plan).
func GetUserSwarmEnabled(userId int) bool {
if userId <= 0 || DB == nil {
return false
}
now := common.GetTimestamp()
var subs []UserSubscription
if err := DB.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now).
Find(&subs).Error; err != nil || len(subs) == 0 {
return false
}
for _, s := range subs {
plan, err := GetSubscriptionPlanById(s.PlanId)
if err != nil || plan == nil {
continue
}
if plan.SwarmEnabled {
return true
}
}
return false
}
// GetAllUserSubscriptions returns all subscriptions (active and expired) for a user.
func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
if userId <= 0 {
@@ -0,0 +1,52 @@
package model
import (
"testing"
"github.com/heicode/manager/common"
"github.com/stretchr/testify/require"
)
// GetUserMaxAgents drives the tier-aware deploy cap (#8): highest MaxAgents among
// the user's ACTIVE subscription plans, else the caller's default.
func TestGetUserMaxAgents(t *testing.T) {
// SubscriptionPlan / UserSubscription are migrated by the package TestMain
// (task_cas_test.go); the table already carries the new max_agents column.
now := common.GetTimestamp()
future := now + 100000
const def = 5
teamPlan := SubscriptionPlan{Title: "team-cap", MaxAgents: 8}
require.NoError(t, DB.Create(&teamPlan).Error)
soloPlan := SubscriptionPlan{Title: "solo-cap", MaxAgents: 0} // 0 => fall back to default
require.NoError(t, DB.Create(&soloPlan).Error)
midPlan := SubscriptionPlan{Title: "mid-cap", MaxAgents: 5}
require.NoError(t, DB.Create(&midPlan).Error)
mkSub := func(uid, planId int, end int64) {
require.NoError(t, DB.Create(&UserSubscription{UserId: uid, PlanId: planId, Status: "active", EndTime: end}).Error)
}
// 1. no subscription -> default
require.Equal(t, def, GetUserMaxAgents(990001, def))
// 2. active team plan -> its cap (8)
mkSub(990002, teamPlan.Id, future)
require.Equal(t, 8, GetUserMaxAgents(990002, def))
// 3. active plan with MaxAgents=0 -> default
mkSub(990003, soloPlan.Id, future)
require.Equal(t, def, GetUserMaxAgents(990003, def))
// 4. expired (end_time in the past) -> default, even if row still says active
mkSub(990004, teamPlan.Id, now-100)
require.Equal(t, def, GetUserMaxAgents(990004, def))
// 5. multiple active plans -> highest cap wins (5 and 8 -> 8)
mkSub(990005, midPlan.Id, future)
mkSub(990005, teamPlan.Id, future)
require.Equal(t, 8, GetUserMaxAgents(990005, def))
// guard: invalid user -> default
require.Equal(t, def, GetUserMaxAgents(0, def))
}
@@ -0,0 +1,47 @@
package model
import (
"testing"
"github.com/heicode/manager/common"
"github.com/stretchr/testify/require"
)
// GetUserSwarmEnabled gates swarm access per subscription plan (admin-set,
// opt-in): true iff the user has any ACTIVE plan with SwarmEnabled=true.
func TestGetUserSwarmEnabled(t *testing.T) {
now := common.GetTimestamp()
future := now + 100000
onPlan := SubscriptionPlan{Title: "swarm-on", SwarmEnabled: true}
require.NoError(t, DB.Create(&onPlan).Error)
offPlan := SubscriptionPlan{Title: "swarm-off", SwarmEnabled: false}
require.NoError(t, DB.Create(&offPlan).Error)
mkSub := func(uid, planId int, end int64) {
require.NoError(t, DB.Create(&UserSubscription{UserId: uid, PlanId: planId, Status: "active", EndTime: end}).Error)
}
// 1. no subscription -> false (opt-in default)
require.False(t, GetUserSwarmEnabled(991001))
// 2. active plan with swarm on -> true
mkSub(991002, onPlan.Id, future)
require.True(t, GetUserSwarmEnabled(991002))
// 3. active plan with swarm off -> false
mkSub(991003, offPlan.Id, future)
require.False(t, GetUserSwarmEnabled(991003))
// 4. expired swarm-on plan -> false (not active)
mkSub(991004, onPlan.Id, now-100)
require.False(t, GetUserSwarmEnabled(991004))
// 5. multiple active plans, one on -> true
mkSub(991005, offPlan.Id, future)
mkSub(991005, onPlan.Id, future)
require.True(t, GetUserSwarmEnabled(991005))
// guard: invalid user -> false
require.False(t, GetUserSwarmEnabled(0))
}
+4
View File
@@ -43,6 +43,10 @@ func TestMain(m *testing.M) {
&SubscriptionPlan{},
&SubscriptionOrder{},
&UserSubscription{},
&TelemetryEvent{},
&PreflightConfirmation{},
&AgentDeployment{},
&AgentCallbackEvent{},
); err != nil {
panic("failed to migrate: " + err.Error())
}
+175
View File
@@ -0,0 +1,175 @@
package model
import (
"errors"
"strings"
"gorm.io/gorm"
)
// TelemetryEvent stores client error-telemetry (issue #24). It is deliberately
// isolated from billing: ingest never writes a consume Log nor touches
// user.Quota. Event payload carries NO user content — only hashes, enums,
// counts and sanitized stack frames (the client sanitizes before sending). The
// device_id (ClientId) is account-linkable via UserId — this association is the
// reason the privacy policy must disclose "device ID (account-linkable)".
type TelemetryEvent struct {
Id int `json:"id"`
ReceivedAt int64 `json:"received_at" gorm:"index"` // server unix seconds
UserId int `json:"user_id" gorm:"index"` // device -> account link
ClientId string `json:"client_id" gorm:"type:varchar(64);index"` // device_id
SchemaVersion int `json:"schema_version" gorm:"default:1"`
AppVersion string `json:"app_version" gorm:"type:varchar(64);default:''"`
Platform string `json:"platform" gorm:"type:varchar(32);default:''"`
OsVersion string `json:"os_version" gorm:"type:varchar(64);default:''"`
Arch string `json:"arch" gorm:"type:varchar(32);default:''"`
Locale string `json:"locale" gorm:"type:varchar(32);default:''"`
ErrorCategory string `json:"error_category" gorm:"type:varchar(40);index;default:''"`
ErrorCode string `json:"error_code" gorm:"type:varchar(128);default:''"`
ErrorMessageHash string `json:"error_message_hash" gorm:"type:varchar(32);default:''"`
StackHash string `json:"stack_hash" gorm:"type:varchar(32);default:''"`
StackTopJSON string `json:"stack_top" gorm:"type:text"` // JSON array, sanitized frames
ContextJSON string `json:"context" gorm:"type:text"` // JSON object, non-content
EventTimestamp string `json:"timestamp" gorm:"type:varchar(40);default:''"` // client ISO8601
SessionSeq int `json:"session_seq" gorm:"default:0"` // process-local
}
// InsertTelemetryEvents batch-inserts ingested telemetry. Uses LOG_DB (the same
// store as Log) since this is diagnostic, append-only, non-billing data.
func InsertTelemetryEvents(events []TelemetryEvent) error {
if len(events) == 0 {
return nil
}
return LOG_DB.Create(&events).Error
}
// TelemetryQueryFilter scopes admin telemetry queries. Empty fields are ignored.
// Time bounds are server unix seconds (ReceivedAt). No content fields exist to
// filter on by design — only enums/hashes/versions.
type TelemetryQueryFilter struct {
UserId int
ClientId string
Platform string
AppVersion string
ErrorCategory string
ErrorCode string
StartReceivedAt int64
EndReceivedAt int64
}
func (f TelemetryQueryFilter) apply(db *gorm.DB) *gorm.DB {
if f.UserId > 0 {
db = db.Where("user_id = ?", f.UserId)
}
if strings.TrimSpace(f.ClientId) != "" {
db = db.Where("client_id = ?", strings.TrimSpace(f.ClientId))
}
if strings.TrimSpace(f.Platform) != "" {
db = db.Where("platform = ?", strings.TrimSpace(f.Platform))
}
if strings.TrimSpace(f.AppVersion) != "" {
db = db.Where("app_version = ?", strings.TrimSpace(f.AppVersion))
}
if strings.TrimSpace(f.ErrorCategory) != "" {
db = db.Where("error_category = ?", strings.TrimSpace(f.ErrorCategory))
}
if strings.TrimSpace(f.ErrorCode) != "" {
db = db.Where("error_code = ?", strings.TrimSpace(f.ErrorCode))
}
if f.StartReceivedAt > 0 {
db = db.Where("received_at >= ?", f.StartReceivedAt)
}
if f.EndReceivedAt > 0 {
db = db.Where("received_at <= ?", f.EndReceivedAt)
}
return db
}
// ListTelemetryEvents returns a filtered, paged slice of telemetry rows (newest
// first) plus the total matching count. Admin-only read path (#24 follow-up).
func ListTelemetryEvents(filter TelemetryQueryFilter, startIdx, pageSize int) ([]TelemetryEvent, int64, error) {
if LOG_DB == nil {
return nil, 0, nil
}
if pageSize <= 0 {
pageSize = 20
}
if startIdx < 0 {
startIdx = 0
}
var total int64
if err := filter.apply(LOG_DB.Model(&TelemetryEvent{})).Count(&total).Error; err != nil {
return nil, 0, err
}
var events []TelemetryEvent
if total == 0 {
return events, 0, nil
}
err := filter.apply(LOG_DB.Model(&TelemetryEvent{})).
Order("received_at desc").
Limit(pageSize).Offset(startIdx).
Find(&events).Error
return events, total, err
}
// telemetryAggDimensions whitelists the columns admins may group by, mapping the
// public dimension name to a real column. The whitelist is the SQL-injection
// guard — the dimension is interpolated into GROUP BY/SELECT, so it MUST come
// from this map, never from raw client input.
var telemetryAggDimensions = map[string]string{
"error_category": "error_category",
"error_code": "error_code",
"platform": "platform",
"app_version": "app_version",
"os_version": "os_version",
"arch": "arch",
"stack_hash": "stack_hash",
}
// TelemetryAggBucket is one group in an aggregate: the dimension value, the row
// count, and the number of distinct accounts that produced it.
type TelemetryAggBucket struct {
Key string `json:"key"`
Count int64 `json:"count"`
Users int64 `json:"users"`
}
// AggregateTelemetryEvents groups matching telemetry by one whitelisted
// dimension, returning counts (and distinct-user counts) per bucket, busiest
// first. Cross-DB safe: only COUNT/COUNT(DISTINCT)/GROUP BY on a fixed column.
func AggregateTelemetryEvents(filter TelemetryQueryFilter, dimension string, limit int) ([]TelemetryAggBucket, error) {
col, ok := telemetryAggDimensions[strings.TrimSpace(dimension)]
if !ok {
return nil, errors.New("unsupported telemetry aggregate dimension")
}
if LOG_DB == nil {
return nil, nil
}
if limit <= 0 || limit > 500 {
limit = 100
}
var buckets []TelemetryAggBucket
err := filter.apply(LOG_DB.Model(&TelemetryEvent{})).
Select(col+" as key, COUNT(*) as count, COUNT(DISTINCT user_id) as users").
Group(col).
Order("count desc").
Limit(limit).
Scan(&buckets).Error
return buckets, err
}
// DeleteTelemetryEventsBefore removes telemetry rows received before cutoffUnix
// (server unix seconds), enforcing the retention window (#32). Returns the
// number of rows deleted. Account-linkable device telemetry must not be kept
// indefinitely; callers run this from a periodic retention task.
func DeleteTelemetryEventsBefore(cutoffUnix int64) (int64, error) {
if LOG_DB == nil {
return 0, nil
}
res := LOG_DB.Where("received_at < ?", cutoffUnix).Delete(&TelemetryEvent{})
return res.RowsAffected, res.Error
}
+90
View File
@@ -0,0 +1,90 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
// 管理员遥测查询:列表过滤 + 分页(最新在前)、聚合(按维度计数 + 去重用户数)、维度白名单。
// TelemetryEvent 由包级 TestMain 迁移;LOG_DB == DB。
func seedTelemetry(t *testing.T) {
t.Helper()
require.NoError(t, LOG_DB.Where("1 = 1").Delete(&TelemetryEvent{}).Error)
rows := []TelemetryEvent{
{ReceivedAt: 1000, UserId: 1, ClientId: "dev-a", Platform: "darwin", AppVersion: "1.0.0", ErrorCategory: "network", ErrorCode: "ETIMEDOUT"},
{ReceivedAt: 2000, UserId: 1, ClientId: "dev-a", Platform: "darwin", AppVersion: "1.0.0", ErrorCategory: "network", ErrorCode: "ECONNRESET"},
{ReceivedAt: 3000, UserId: 2, ClientId: "dev-b", Platform: "windows", AppVersion: "1.1.0", ErrorCategory: "network", ErrorCode: "ETIMEDOUT"},
{ReceivedAt: 4000, UserId: 3, ClientId: "dev-c", Platform: "windows", AppVersion: "1.1.0", ErrorCategory: "crash", ErrorCode: "SIGSEGV"},
}
for i := range rows {
rows[i].SchemaVersion = 1
require.NoError(t, LOG_DB.Create(&rows[i]).Error)
}
}
func TestListTelemetryEvents_FilterAndPaging(t *testing.T) {
seedTelemetry(t)
// no filter -> all 4, newest first.
events, total, err := ListTelemetryEvents(TelemetryQueryFilter{}, 0, 10)
require.NoError(t, err)
require.EqualValues(t, 4, total)
require.Len(t, events, 4)
require.EqualValues(t, 4000, events[0].ReceivedAt, "newest first")
require.EqualValues(t, 1000, events[3].ReceivedAt)
// platform filter.
events, total, err = ListTelemetryEvents(TelemetryQueryFilter{Platform: "windows"}, 0, 10)
require.NoError(t, err)
require.EqualValues(t, 2, total)
require.Len(t, events, 2)
// user + category filter.
_, total, err = ListTelemetryEvents(TelemetryQueryFilter{UserId: 1, ErrorCategory: "network"}, 0, 10)
require.NoError(t, err)
require.EqualValues(t, 2, total)
// time range (received_at in [2000,3000]).
_, total, err = ListTelemetryEvents(TelemetryQueryFilter{StartReceivedAt: 2000, EndReceivedAt: 3000}, 0, 10)
require.NoError(t, err)
require.EqualValues(t, 2, total)
// pagination: page size 2 -> total stays 4, page returns 2.
events, total, err = ListTelemetryEvents(TelemetryQueryFilter{}, 0, 2)
require.NoError(t, err)
require.EqualValues(t, 4, total)
require.Len(t, events, 2)
events2, _, err := ListTelemetryEvents(TelemetryQueryFilter{}, 2, 2)
require.NoError(t, err)
require.Len(t, events2, 2)
require.NotEqual(t, events[0].Id, events2[0].Id, "pages are disjoint")
}
func TestAggregateTelemetryEvents_CountsAndDistinctUsers(t *testing.T) {
seedTelemetry(t)
// by error_category: network=3 (users 1,2 -> 2 distinct), crash=1 (user 3).
buckets, err := AggregateTelemetryEvents(TelemetryQueryFilter{}, "error_category", 0)
require.NoError(t, err)
require.Len(t, buckets, 2)
require.Equal(t, "network", buckets[0].Key, "busiest first")
require.EqualValues(t, 3, buckets[0].Count)
require.EqualValues(t, 2, buckets[0].Users, "distinct accounts")
require.Equal(t, "crash", buckets[1].Key)
require.EqualValues(t, 1, buckets[1].Count)
require.EqualValues(t, 1, buckets[1].Users)
// by platform with a filter applied (only network rows): darwin=2, windows=1.
buckets, err = AggregateTelemetryEvents(TelemetryQueryFilter{ErrorCategory: "network"}, "platform", 0)
require.NoError(t, err)
require.Len(t, buckets, 2)
require.Equal(t, "darwin", buckets[0].Key)
require.EqualValues(t, 2, buckets[0].Count)
// unsupported dimension is rejected (SQL-injection guard).
_, err = AggregateTelemetryEvents(TelemetryQueryFilter{}, "user_id; drop table", 0)
require.Error(t, err)
_, err = AggregateTelemetryEvents(TelemetryQueryFilter{}, "", 0)
require.Error(t, err)
}
+38
View File
@@ -0,0 +1,38 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
// #43: the retention purge must delete telemetry rows received before the
// cutoff and leave newer rows intact. TelemetryEvent is migrated by the package
// TestMain; LOG_DB == DB in tests.
func TestDeleteTelemetryEventsBefore(t *testing.T) {
// Isolate from any other test's rows.
require.NoError(t, LOG_DB.Where("1 = 1").Delete(&TelemetryEvent{}).Error)
mk := func(receivedAt int64, client string) {
require.NoError(t, LOG_DB.Create(&TelemetryEvent{
ReceivedAt: receivedAt, UserId: 1, ClientId: client, SchemaVersion: 1,
}).Error)
}
mk(1000, "old-1") // before cutoff -> deleted
mk(2999, "old-2") // before cutoff -> deleted
mk(3000, "keep-1") // == cutoff -> kept (strict <)
mk(5000, "keep-2") // after cutoff -> kept
deleted, err := DeleteTelemetryEventsBefore(3000)
require.NoError(t, err)
require.EqualValues(t, 2, deleted, "rows with received_at < cutoff are purged")
var remaining int64
require.NoError(t, LOG_DB.Model(&TelemetryEvent{}).Count(&remaining).Error)
require.EqualValues(t, 2, remaining, "rows at/after cutoff are retained")
// Idempotent: a second pass with the same cutoff deletes nothing.
again, err := DeleteTelemetryEventsBefore(3000)
require.NoError(t, err)
require.EqualValues(t, 0, again)
}
+36
View File
@@ -63,6 +63,9 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/agent/callbacks/runtime-events", controller.AgentReceiveRuntimeEventCallback)
// Client-facing capability discovery (unified spec §6). Catalog data only.
apiRouter.GET("/heicode/capabilities", controller.HeicodeCapabilities)
// Client runtime config (#24): telemetry enable/endpoint/kill-switch etc.
// Pollable within a session so kill switches propagate without re-login.
apiRouter.GET("/heicode/config", controller.HeicodeConfig)
apiRouter.POST("/swarms", middleware.UserOrV2DeviceAuth(), controller.AgentCreateUserSwarm)
//apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
@@ -200,6 +203,15 @@ func SetApiRouter(router *gin.Engine) {
subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription)
}
// Client telemetry admin read-only views (#24 follow-up): list + aggregate.
// Admin-gated — device_id is account-linkable, so only admins may read.
telemetryAdminRoute := apiRouter.Group("/telemetry")
telemetryAdminRoute.Use(middleware.AdminAuth())
{
telemetryAdminRoute.GET("/events", controller.AdminListTelemetryEvents)
telemetryAdminRoute.GET("/aggregate", controller.AdminAggregateTelemetryEvents)
}
// Subscription payment callbacks (no auth)
apiRouter.POST("/subscription/epay/notify", controller.SubscriptionEpayNotify)
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
@@ -222,6 +234,9 @@ func SetApiRouter(router *gin.Engine) {
resourceRoute.POST("/", controller.CreateResource)
resourceRoute.POST("/:id/secret", controller.UpsertResourceSecret)
resourceRoute.POST("/:id/azure/discover", controller.DiscoverAzureResources)
// Provider-agnostic cloud discovery (#5): routes by the account's provider
// (azure/aws/gcp). The azure-specific route above is kept for back-compat.
resourceRoute.POST("/:id/discover-cloud", controller.DiscoverCloudResources)
resourceRoute.PUT("/:id", controller.UpdateResource)
resourceRoute.DELETE("/:id", controller.DeleteResource)
}
@@ -531,12 +546,33 @@ func SetApiRouter(router *gin.Engine) {
heicodeAgentRoute.Use(middleware.UserOrV2DeviceAuth())
{
heicodeAgentRoute.GET("/agent-templates", controller.HeicodeListAgentTemplates)
heicodeAgentRoute.GET("/available-models", controller.HeicodeAvailableModels)
// Preflight / execution-summary (#39 缺失项检测 + #40 可读摘要 + #41 confirm/版本).
heicodeAgentRoute.GET("/preflight", controller.HeicodePreflight)
heicodeAgentRoute.POST("/preflight/confirm", controller.HeicodePreflightConfirm)
heicodeAgentRoute.POST("/agents", controller.HeicodeDeployAgent)
heicodeAgentRoute.GET("/agents", controller.HeicodeListAgents)
heicodeAgentRoute.GET("/agents/:deployment_id", controller.HeicodeGetAgent)
heicodeAgentRoute.GET("/agents/:deployment_id/status", controller.HeicodeGetAgentStatus)
heicodeAgentRoute.GET("/agents/:deployment_id/usage", controller.HeicodeGetAgentUsage)
heicodeAgentRoute.POST("/agents/:deployment_id/stop", controller.HeicodeStopAgent)
heicodeAgentRoute.DELETE("/agents/:deployment_id", controller.HeicodeDeleteAgent)
// Client error-telemetry ingest (#24). Device-paired; never bills.
// Gated by HEICODE_TELEMETRY_ENABLED (default off -> 410 kill switch).
heicodeAgentRoute.POST("/telemetry/events", controller.HeicodeTelemetryEvents)
// Swarm Run query (#45 Phase1). Read-only views served from HM-persisted
// callback data (no live Swarm call); stop calls the runtime (runtime-contract v1,
// gated by SWARM_RUNTIME_ENABLED + base_url + service token).
heicodeAgentRoute.GET("/swarms", controller.HeicodeListSwarms)
heicodeAgentRoute.GET("/swarms/:id", controller.HeicodeGetSwarmStatus)
heicodeAgentRoute.GET("/swarms/:id/events", controller.HeicodeListSwarmEvents)
heicodeAgentRoute.GET("/swarms/:id/events/stream", controller.HeicodeStreamSwarmEvents)
heicodeAgentRoute.GET("/swarms/:id/artifacts", controller.HeicodeListSwarmArtifacts)
heicodeAgentRoute.POST("/swarms/:id/stop", controller.HeicodeStopSwarm)
// I/O 闭环(#46/#28):追加输入(写,代理到 Swarm POST …/input)+ 结果交付(读,
// 代理到 Swarm GET …/result)。契约见 agent_swarm PR#41 / CLIENT_GUIDE §9。
heicodeAgentRoute.POST("/swarms/:id/input", controller.HeicodeAppendSwarmInput)
heicodeAgentRoute.GET("/swarms/:id/result", controller.HeicodeGetSwarmResult)
}
// Client↔agent access control (HM-provided, AM-optional). Called by the
@@ -1,5 +1,6 @@
import { type TFunction } from 'i18next'
import {
Activity,
Box,
Boxes,
Building2,
@@ -84,6 +85,11 @@ export function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
url: '/usage-logs/common',
icon: ClipboardList,
},
{
title: t('Client Telemetry'),
url: '/telemetry',
icon: Activity,
},
],
},
@@ -49,7 +49,7 @@ const workspaceRegistry: WorkspaceConfig[] = [
id: WORKSPACE_IDS.SYSTEM_SETTINGS,
name: 'System Settings',
pathPattern:
/^\/(system-settings|channels|redemption-codes|users|templates|agents|subscriptions|models|usage-logs)(\/|$)/,
/^\/(system-settings|channels|redemption-codes|users|templates|agents|subscriptions|models|usage-logs|telemetry)(\/|$)/,
getNavGroups: getSystemSettingsNavGroups,
},
// Default workspace (must be last)
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Bot,
@@ -112,8 +112,18 @@ export function DeploySubAgentPage() {
const agentsQ = useQuery({
queryKey: ['heicode-agents'],
queryFn: listAgents,
refetchInterval: 30_000,
retry: false,
// Async deploy returns Pending immediately and flips to running seconds later.
// Poll fast while anything is still starting, then back off once settled.
refetchInterval: (q) => {
const items = q.state.data ?? []
const settling = items.some((a) =>
['pending', 'starting', 'provisioning'].includes(
(a.status || '').toLowerCase()
)
)
return settling ? 3_000 : 30_000
},
})
const deploy = useMutation({
@@ -135,6 +145,31 @@ export function DeploySubAgentPage() {
toast.error(e instanceof Error ? e.message : '部署失败,请稍后重试'),
})
// Prominent "done" feedback for async deploy: the POST returns immediately with
// Pending; the agent flips to running (or failed) seconds later via the polling
// above. Diff each refresh against the previous statuses and toast on the
// transition, so the user gets a clear signal instead of silently watching.
const prevStatus = useRef<Record<string, string>>({})
useEffect(() => {
const items = agentsQ.data
if (!items) return
const prev = prevStatus.current
const next: Record<string, string> = {}
const starting = ['pending', 'starting', 'provisioning']
for (const a of items) {
const cur = (a.status || '').toLowerCase()
next[a.agent_id] = cur
const was = prev[a.agent_id]
if (was && was !== cur) {
if (starting.includes(was) && ['running', 'active', 'ready'].includes(cur))
toast.success('✅ Agent 部署完成,已就绪')
else if (['failed', 'error', 'unhealthy', 'crashed'].includes(cur))
toast.error('Agent 部署失败,请查看状态')
}
}
prevStatus.current = next
}, [agentsQ.data])
const toggleBinding = (id: number) =>
setSelectedBindings((s) =>
s.includes(id) ? s.filter((x) => x !== id) : [...s, id]
@@ -332,6 +367,9 @@ function AgentRow({
}) {
const qc = useQueryClient()
const st = statusLabel(agent.status)
const starting = ['pending', 'starting', 'provisioning'].includes(
(agent.status || '').toLowerCase()
)
const stop = useMutation({
mutationFn: async () => {
@@ -379,10 +417,12 @@ function AgentRow({
<span className='truncate text-sm font-medium'>{templateName}</span>
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset',
st.cls
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset',
st.cls,
starting && 'animate-pulse'
)}
>
{starting && <RefreshCw className='h-2.5 w-2.5 animate-spin' />}
{st.text}
</span>
</div>
@@ -286,6 +286,30 @@ export function SubscriptionsMutateDrawer({
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_agents'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Agent Deploy Limit')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min={0}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
/>
</FormControl>
<FormDescription>
{t('0 uses the global default cap')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-2 gap-3'>
@@ -326,6 +350,24 @@ export function SubscriptionsMutateDrawer({
</FormItem>
)}
/>
<FormField
control={form.control}
name='swarm_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center gap-2 pt-8'>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className='!mt-0'>
{t('Swarm Access')}
</FormLabel>
</FormItem>
)}
/>
</div>
</div>
@@ -21,6 +21,8 @@ export function getPlanFormSchema(t: TFunction) {
enabled: z.boolean(),
sort_order: z.coerce.number(),
max_purchase_per_user: z.coerce.number().min(0),
max_agents: z.coerce.number().min(0),
swarm_enabled: z.boolean(),
total_amount: z.coerce.number().min(0),
upgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(),
@@ -42,6 +44,8 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
enabled: true,
sort_order: 0,
max_purchase_per_user: 0,
max_agents: 0,
swarm_enabled: false,
total_amount: 0,
upgrade_group: '',
stripe_price_id: '',
@@ -61,6 +65,8 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
enabled: plan.enabled !== false,
sort_order: Number(plan.sort_order || 0),
max_purchase_per_user: Number(plan.max_purchase_per_user || 0),
max_agents: Number(plan.max_agents || 0),
swarm_enabled: plan.swarm_enabled === true,
total_amount: Number(plan.total_amount || 0),
upgrade_group: plan.upgrade_group || '',
stripe_price_id: plan.stripe_price_id || '',
@@ -83,6 +89,7 @@ export function formValuesToPlanPayload(values: PlanFormValues): PlanPayload {
: 0,
sort_order: Number(values.sort_order || 0),
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
max_agents: Number(values.max_agents || 0),
total_amount: Number(values.total_amount || 0),
upgrade_group: values.upgrade_group || '',
},
@@ -18,6 +18,8 @@ export const subscriptionPlanSchema = z.object({
enabled: z.boolean(),
sort_order: z.number(),
max_purchase_per_user: z.number(),
max_agents: z.number(),
swarm_enabled: z.boolean().optional().default(false),
total_amount: z.number(),
upgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(),
+37
View File
@@ -0,0 +1,37 @@
import { api } from '@/lib/api'
import type {
ApiResponse,
TelemetryAggregateData,
TelemetryFilters,
TelemetryListData,
} from './types'
function buildParams(params: Record<string, unknown>): string {
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null) continue
const s = String(v).trim()
if (s !== '') sp.set(k, s)
}
return sp.toString()
}
export async function getTelemetryEvents(
page: number,
pageSize: number,
filters: TelemetryFilters
): Promise<ApiResponse<TelemetryListData>> {
const query = buildParams({ p: page, page_size: pageSize, ...filters })
const res = await api.get(`/api/telemetry/events?${query}`)
return res.data
}
export async function getTelemetryAggregate(
dimension: string,
filters: TelemetryFilters,
limit = 100
): Promise<ApiResponse<TelemetryAggregateData>> {
const query = buildParams({ dimension, limit, ...filters })
const res = await api.get(`/api/telemetry/aggregate?${query}`)
return res.data
}
+218
View File
@@ -0,0 +1,218 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { getTelemetryAggregate, getTelemetryEvents } from './api'
import {
TELEMETRY_AGG_DIMENSIONS,
type TelemetryFilters,
} from './types'
const PAGE_SIZE = 20
const EMPTY_FILTERS: TelemetryFilters = {
user_id: '',
client_id: '',
platform: '',
app_version: '',
error_category: '',
error_code: '',
}
function fmtTime(unix: number): string {
if (!unix) return '-'
return new Date(unix * 1000).toLocaleString()
}
export function Telemetry() {
const { t } = useTranslation()
// Draft filters bound to inputs; `applied` is what queries actually use.
const [draft, setDraft] = useState<TelemetryFilters>(EMPTY_FILTERS)
const [applied, setApplied] = useState<TelemetryFilters>(EMPTY_FILTERS)
const [dimension, setDimension] = useState<string>('error_category')
const [page, setPage] = useState(1)
const { data: listResp, isLoading: listLoading } = useQuery({
queryKey: ['admin-telemetry-events', page, applied],
queryFn: async () => (await getTelemetryEvents(page, PAGE_SIZE, applied)).data,
placeholderData: (prev) => prev,
})
const { data: aggResp, isLoading: aggLoading } = useQuery({
queryKey: ['admin-telemetry-aggregate', dimension, applied],
queryFn: async () => (await getTelemetryAggregate(dimension, applied)).data,
placeholderData: (prev) => prev,
})
const events = useMemo(() => listResp?.items ?? [], [listResp])
const total = listResp?.total ?? 0
const buckets = useMemo(() => aggResp?.buckets ?? [], [aggResp])
const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0) || 1
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const apply = () => {
setPage(1)
setApplied(draft)
}
const reset = () => {
setDraft(EMPTY_FILTERS)
setApplied(EMPTY_FILTERS)
setPage(1)
}
const setField = (k: keyof TelemetryFilters) => (e: React.ChangeEvent<HTMLInputElement>) =>
setDraft((d) => ({ ...d, [k]: e.target.value }))
return (
<SectionPageLayout>
<SectionPageLayout.Title>{t('Client Telemetry')}</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t(
'Diagnostic error telemetry from clients. No user content — only categories, codes, hashes and versions. device_id is account-linkable.'
)}
</SectionPageLayout.Description>
<SectionPageLayout.Content>
<div className='space-y-4'>
{/* Filters */}
<div className='grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6'>
<Input placeholder={t('User ID')} value={draft.user_id} onChange={setField('user_id')} />
<Input placeholder={t('Device ID')} value={draft.client_id} onChange={setField('client_id')} />
<Input placeholder={t('Platform')} value={draft.platform} onChange={setField('platform')} />
<Input placeholder={t('App version')} value={draft.app_version} onChange={setField('app_version')} />
<Input placeholder={t('Error category')} value={draft.error_category} onChange={setField('error_category')} />
<Input placeholder={t('Error code')} value={draft.error_code} onChange={setField('error_code')} />
</div>
<div className='flex gap-2'>
<Button size='sm' onClick={apply}>{t('Apply')}</Button>
<Button size='sm' variant='outline' onClick={reset}>{t('Reset')}</Button>
</div>
{/* Aggregate */}
<Card>
<CardHeader className='flex flex-row items-center justify-between gap-2 space-y-0'>
<CardTitle className='text-sm'>{t('Aggregate')}</CardTitle>
<Select value={dimension} onValueChange={setDimension}>
<SelectTrigger className='w-44'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TELEMETRY_AGG_DIMENSIONS.map((d) => (
<SelectItem key={d} value={d}>{d}</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{aggLoading && buckets.length === 0 ? (
<p className='text-muted-foreground text-sm'>{t('Loading...')}</p>
) : buckets.length === 0 ? (
<p className='text-muted-foreground text-sm'>{t('No data')}</p>
) : (
<div className='space-y-1.5'>
{buckets.map((b) => (
<div key={b.key || '(empty)'} className='flex items-center gap-2 text-sm'>
<span className='w-40 truncate font-mono text-xs' title={b.key}>
{b.key || '(empty)'}
</span>
<div className='bg-muted h-4 flex-1 overflow-hidden rounded'>
<div
className='bg-primary h-full'
style={{ width: `${(b.count / maxCount) * 100}%` }}
/>
</div>
<span className='w-12 text-right tabular-nums'>{b.count}</span>
<Badge variant='secondary' className='tabular-nums' title={t('Distinct users')}>
{b.users}u
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Event list */}
<div className='rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('Time')}</TableHead>
<TableHead>{t('User ID')}</TableHead>
<TableHead>{t('Device ID')}</TableHead>
<TableHead>{t('Platform')}</TableHead>
<TableHead>{t('App version')}</TableHead>
<TableHead>{t('Error category')}</TableHead>
<TableHead>{t('Error code')}</TableHead>
<TableHead>{t('Stack hash')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{listLoading && events.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className='text-muted-foreground h-20 text-center'>
{t('Loading...')}
</TableCell>
</TableRow>
) : events.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className='text-muted-foreground h-20 text-center'>
{t('No data')}
</TableCell>
</TableRow>
) : (
events.map((ev) => (
<TableRow key={ev.id}>
<TableCell className='whitespace-nowrap'>{fmtTime(ev.received_at)}</TableCell>
<TableCell className='tabular-nums'>{ev.user_id}</TableCell>
<TableCell className='max-w-32 truncate font-mono text-xs' title={ev.client_id}>{ev.client_id}</TableCell>
<TableCell>{ev.platform}</TableCell>
<TableCell>{ev.app_version}</TableCell>
<TableCell>{ev.error_category}</TableCell>
<TableCell className='max-w-40 truncate' title={ev.error_code}>{ev.error_code}</TableCell>
<TableCell className='font-mono text-xs'>{ev.stack_hash}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Total')}: {total}
</span>
<div className='flex items-center gap-2'>
<Button size='sm' variant='outline' disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
{t('Previous')}
</Button>
<span className='text-sm tabular-nums'>{page} / {totalPages}</span>
<Button size='sm' variant='outline' disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
{t('Next')}
</Button>
</div>
</div>
</div>
</SectionPageLayout.Content>
</SectionPageLayout>
)
}
+71
View File
@@ -0,0 +1,71 @@
// Client error-telemetry (#24) admin read-only views. No user content — only
// enums/hashes/versions/counts. device_id (client_id) is account-linkable, so
// these views are admin-gated.
export interface ApiResponse<T = unknown> {
success: boolean
message?: string
data?: T
}
export interface TelemetryEvent {
id: number
received_at: number
user_id: number
client_id: string
schema_version: number
app_version: string
platform: string
os_version: string
arch: string
locale: string
error_category: string
error_code: string
error_message_hash: string
stack_hash: string
stack_top: string
context: string
timestamp: string
session_seq: number
}
export interface TelemetryListData {
items: TelemetryEvent[]
total: number
page: number
page_size: number
}
export interface TelemetryAggBucket {
key: string
count: number
users: number
}
export interface TelemetryAggregateData {
dimension: string
buckets: TelemetryAggBucket[]
}
export interface TelemetryFilters {
user_id?: string
client_id?: string
platform?: string
app_version?: string
error_category?: string
error_code?: string
start_timestamp?: string
end_timestamp?: string
}
export const TELEMETRY_AGG_DIMENSIONS = [
'error_category',
'error_code',
'platform',
'app_version',
'os_version',
'arch',
'stack_hash',
] as const
export type TelemetryAggDimension = (typeof TELEMETRY_AGG_DIMENSIONS)[number]
+6 -22
View File
@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { getSelf } from '@/lib/api'
import { getHeicodeBalance } from '@/lib/heicode-mcp'
import { useStatus } from '@/hooks/use-status'
import { useSystemConfig } from '@/hooks/use-system-config'
import { SectionPageLayout } from '@/components/layout'
@@ -87,30 +86,15 @@ export function Wallet(props: WalletProps) {
useWaffoPancakePayment()
// Fetch and refresh user data.
// Per docs/product-package/13 §6 "模型与余额" + Heicode-接口契约文档 §4,
// the canonical balance source is mcp-server /api/user/heicode/balance
// (server-side wraps NewAPI admin token, returns the freshest figures).
// Fall back to NewAPI /api/user/self when the user hasn't yet been
// mirrored into NewAPI via from-agent (HEICODE_USER_NOT_FOUND) so a
// brand-new account still sees something instead of empty stats.
// HM IS new-api: the caller's quota lives in HM's own user row, so read it
// directly via GET /api/user/self (GetSelf — the user's own data, no admin
// lookup, no permission gate, freshest figures). The old path detoured the
// balance through the /api/heicode-auth proxy to mcp-server (the AM-side
// login service — a different project that does not own balance), which
// 502'd on NewAPI's same-level admin check (controller/user.go GetUser).
const fetchUser = useCallback(async () => {
try {
setUserLoading(true)
const heicodeBalance = await getHeicodeBalance().catch(() => null)
if (heicodeBalance) {
setUser({
id: heicodeBalance.heicodeUserId,
username: heicodeBalance.username,
group: heicodeBalance.group,
quota: heicodeBalance.quota,
used_quota: heicodeBalance.usedQuota,
request_count: heicodeBalance.requestCount,
aff_quota: 0,
aff_history_quota: 0,
aff_count: 0,
})
return
}
const response = await getSelf()
if (response.success && response.data) {
setUser(response.data as UserWalletData)
+1
View File
@@ -113,6 +113,7 @@ export function useSidebarData(): SidebarData {
'/subscriptions',
'/models',
'/usage-logs',
'/telemetry',
],
icon: Settings,
},
+12
View File
@@ -1,5 +1,14 @@
{
"translation": {
"Client Telemetry": "客户端遥测",
"Diagnostic error telemetry from clients. No user content — only categories, codes, hashes and versions. device_id is account-linkable.": "来自客户端的诊断错误遥测。不含用户内容——仅类别、代码、哈希与版本号;device_id 可关联账号。",
"Device ID": "设备 ID",
"Error category": "错误类别",
"Error code": "错误代码",
"Apply": "应用",
"Aggregate": "聚合",
"Stack hash": "堆栈哈希",
"Distinct users": "去重用户数",
"\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"",
"% off": "折",
"(Leave empty to dissolve tag)": "(留空以删除标签)",
@@ -13,7 +22,10 @@
"/status/": "/status/",
"/your/endpoint": "/your/endpoint",
"0 means unlimited": "0 表示不限",
"0 uses the global default cap": "0 表示使用全局默认上限",
"1 Day": "1 天",
"Agent Deploy Limit": "Agent 部署上限",
"Swarm Access": "蜂群使用权",
"1 day ago": "1 天前",
"1 Hour": "1 小时",
"1 hour ago": "1 小时前",
+22
View File
@@ -35,6 +35,7 @@ import { Route as PricingModelIdIndexRouteImport } from './routes/pricing/$model
import { Route as AuthenticatedWalletIndexRouteImport } from './routes/_authenticated/wallet/index'
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
import { Route as AuthenticatedUsageLogsIndexRouteImport } from './routes/_authenticated/usage-logs/index'
import { Route as AuthenticatedTelemetryIndexRouteImport } from './routes/_authenticated/telemetry/index'
import { Route as AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/index'
import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
import { Route as AuthenticatedResourcesIndexRouteImport } from './routes/_authenticated/resources/index'
@@ -202,6 +203,12 @@ const AuthenticatedUsageLogsIndexRoute =
path: '/usage-logs/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedTelemetryIndexRoute =
AuthenticatedTelemetryIndexRouteImport.update({
id: '/telemetry/',
path: '/telemetry/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedSystemSettingsIndexRoute =
AuthenticatedSystemSettingsIndexRouteImport.update({
id: '/',
@@ -452,6 +459,7 @@ export interface FileRoutesByFullPath {
'/resources/': typeof AuthenticatedResourcesIndexRoute
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
'/telemetry/': typeof AuthenticatedTelemetryIndexRoute
'/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
'/users/': typeof AuthenticatedUsersIndexRoute
'/wallet/': typeof AuthenticatedWalletIndexRoute
@@ -512,6 +520,7 @@ export interface FileRoutesByTo {
'/resources': typeof AuthenticatedResourcesIndexRoute
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
'/telemetry': typeof AuthenticatedTelemetryIndexRoute
'/usage-logs': typeof AuthenticatedUsageLogsIndexRoute
'/users': typeof AuthenticatedUsersIndexRoute
'/wallet': typeof AuthenticatedWalletIndexRoute
@@ -576,6 +585,7 @@ export interface FileRoutesById {
'/_authenticated/resources/': typeof AuthenticatedResourcesIndexRoute
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
'/_authenticated/telemetry/': typeof AuthenticatedTelemetryIndexRoute
'/_authenticated/usage-logs/': typeof AuthenticatedUsageLogsIndexRoute
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
'/_authenticated/wallet/': typeof AuthenticatedWalletIndexRoute
@@ -639,6 +649,7 @@ export interface FileRouteTypes {
| '/resources/'
| '/subscriptions/'
| '/system-settings/'
| '/telemetry/'
| '/usage-logs/'
| '/users/'
| '/wallet/'
@@ -699,6 +710,7 @@ export interface FileRouteTypes {
| '/resources'
| '/subscriptions'
| '/system-settings'
| '/telemetry'
| '/usage-logs'
| '/users'
| '/wallet'
@@ -762,6 +774,7 @@ export interface FileRouteTypes {
| '/_authenticated/resources/'
| '/_authenticated/subscriptions/'
| '/_authenticated/system-settings/'
| '/_authenticated/telemetry/'
| '/_authenticated/usage-logs/'
| '/_authenticated/users/'
| '/_authenticated/wallet/'
@@ -984,6 +997,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedUsageLogsIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/telemetry/': {
id: '/_authenticated/telemetry/'
path: '/telemetry'
fullPath: '/telemetry/'
preLoaderRoute: typeof AuthenticatedTelemetryIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/system-settings/': {
id: '/_authenticated/system-settings/'
path: '/'
@@ -1335,6 +1355,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
AuthenticatedResourcesIndexRoute: typeof AuthenticatedResourcesIndexRoute
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
AuthenticatedTelemetryIndexRoute: typeof AuthenticatedTelemetryIndexRoute
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute
@@ -1365,6 +1386,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedRedemptionCodesIndexRoute,
AuthenticatedResourcesIndexRoute: AuthenticatedResourcesIndexRoute,
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
AuthenticatedTelemetryIndexRoute: AuthenticatedTelemetryIndexRoute,
AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute,
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
AuthenticatedWalletIndexRoute: AuthenticatedWalletIndexRoute,
@@ -0,0 +1,14 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth-store'
import { ROLE } from '@/lib/roles'
import { Telemetry } from '@/features/telemetry'
export const Route = createFileRoute('/_authenticated/telemetry/')({
beforeLoad: () => {
const { auth } = useAuthStore.getState()
if (!auth.user || auth.user.role < ROLE.ADMIN) {
throw redirect({ to: '/403' })
}
},
component: Telemetry,
})