From ba02ae5be7956e20cd3308a7b3012237a4253bd3 Mon Sep 17 00:00:00 2001 From: gongzhiyong Date: Mon, 4 May 2026 09:28:03 +0800 Subject: [PATCH] feat: align manager agnet boundaries - add Manager user_context, NewAPI billing_context, and Agnet agent_runtime deployment fields - move resource binding/grant scope toward user-owned binding_scope and secret_ref-only paths - document OpenBao internal access and unified heicode.xinghanlab.com routing boundaries - fix Manager session user id preservation after external auth login --- .../azure-production-deploy-guardrails.md | 19 +- ...icode-runtime-auth-newapi-secret-design.md | 8 + .../agnet-platform-request-contract.md | 165 ++++-- heicode/controller/agnet_control_plane.go | 244 +++++++-- .../controller/agnet_control_plane_test.go | 100 +++- heicode/controller/resource.go | 85 ++- heicode/controller/resource_test.go | 52 +- heicode/model/resource.go | 14 +- .../default/src/features/agnet-console/api.ts | 81 ++- .../create-agnet-deployment-sheet.tsx | 69 ++- .../src/features/agnet-console/pages.tsx | 493 ++++++++++-------- heicode/web/default/src/features/auth/api.ts | 3 +- heicode/web/default/src/i18n/locales/en.json | 27 +- heicode/web/default/src/i18n/locales/zh.json | 29 +- 14 files changed, 934 insertions(+), 455 deletions(-) diff --git a/docs/deployment/azure-production-deploy-guardrails.md b/docs/deployment/azure-production-deploy-guardrails.md index 6fe2726..6add47e 100644 --- a/docs/deployment/azure-production-deploy-guardrails.md +++ b/docs/deployment/azure-production-deploy-guardrails.md @@ -2,7 +2,7 @@ 本文用于 Heicode Manager / Agnet / NewAPI 相关生产发布前的人工执行检查。它只描述安全命令、环境变量名和验证项,不保存任何真实地址、账号、密码、Token、连接串、SSH key 或云访问密钥。 -适用范围:Azure VM、Azure PostgreSQL、Azure Redis、Git 同步、Heicode Manager 容器、Agnet 平台联调、NewAPI 网关能力验证。 +适用范围:Azure VM、Azure PostgreSQL、Azure Redis、Git 同步、Nginx 统一入口、Heicode Manager 容器、OpenBao 内网密钥保管、Agnet 平台联调、NewAPI 网关能力验证。 ## 1. 执行原则 @@ -12,6 +12,7 @@ | Git 发布 | 仅允许快进同步已审核提交;禁止在生产 VM 上提交代码或保存临时补丁。 | | 数据库/Redis | Azure PostgreSQL / Redis 连接串只写入 VM 本地 `.env` 或 Secret Store;验证时只打印变量名和连通性结果,不打印值。 | | Agnet / NewAPI | Manager 只传 `secret_ref`、部署计划、资源授权和审计上下文;禁止把明文云账号、数据库密码、模型 Key 放入请求体。 | +| OpenBao | 只允许 Manager/Agnet 受控网络访问;如果 Manager 提供客户端验证和绑定接口,OpenBao 不暴露公网路由。 | | 生产动作 | 执行 `up -d`、迁移、重启、回滚前必须记录当前镜像/提交和健康检查 URL;失败时停止扩大变更。 | ## 2. 本地发布前检查 @@ -91,6 +92,13 @@ ssh "$REMOTE" "cd '$REMOTE_DIR/heicode' && docker compose -f docker-compose.azur 当前仓库的 Heicode 服务保留 NewAPI 网关能力;生产部署以 `heicode/docker-compose.azure-vm.yml` 为入口,PostgreSQL / Redis 使用 Azure 托管实例。 +公网入口约定: + +- 对外域名统一使用 `heicode.xinghanlab.com`。 +- Nginx 负责按路由转发 Manager 与 NewAPI,例如 Manager 主站、NewAPI 受控 API 或健康检查路由。 +- OpenBao 仅供 Manager/Agnet 服务端访问,不通过 `heicode.xinghanlab.com` 暴露给浏览器用户。 +- 若需要 OpenBao 运维 UI,也必须走临时 SSH tunnel、VPN、内网跳板或单独受保护管理入口,不走普通 SaaS 用户路由。 + 构建和启动: ```bash @@ -127,13 +135,14 @@ Manager 请求 Agnet 平台时遵循 `docs/integration/agnet-platform-request-co ```bash export AGNET_BASE_URL='' export MANAGER_SERVICE_TOKEN_SECRET_REF='' -export TENANT_ID='' -export PROJECT_ID='' +export USER_ID='' +export BINDING_SCOPE='' # 真实 token 由运行环境注入;禁止把 token 字面值写入命令历史或文档。 curl -fsS -X POST "$AGNET_BASE_URL/api/agnet/deployments" \ -H 'Content-Type: application/json' \ - -H "X-Tenant-Id: $TENANT_ID" \ + -H "X-User-Id: $USER_ID" \ + -H "X-Binding-Scope: $BINDING_SCOPE" \ -H "Idempotency-Key: deploy-$(date +%Y%m%d%H%M%S)" \ -H "Authorization: Bearer $MANAGER_SERVICE_TOKEN" \ --data @docs/integration/safe-agnet-deploy-example.json @@ -146,6 +155,8 @@ curl -fsS -X POST "$AGNET_BASE_URL/api/agnet/deployments" \ | 验证项 | 安全命令 | 通过标准 | |---|---|---| | 服务健康 | `curl -fsS http://127.0.0.1:3000/api/status` | 返回 `success=true`。 | +| Nginx 公网入口 | `curl -fsS https://heicode.xinghanlab.com/api/status` | 返回 Manager 健康状态;DNS 需解析到生产入口。 | +| OpenBao 暴露面 | `curl -fsSI https://heicode.xinghanlab.com/v1/sys/health` | 普通公网入口不应返回 OpenBao 健康信息;预期为无路由、403 或 404。 | | 容器状态 | `docker compose ... ps heicode` | `heicode` 为 running/healthy。 | | Git 版本 | `git rev-parse --short HEAD` | 与已审核提交一致。 | | DB/Redis 注入 | `awk -F= ... .env` | 只打印 key,包含 `SQL_DSN`、`REDIS_CONN_STRING`。 | diff --git a/docs/heicode-runtime-auth-newapi-secret-design.md b/docs/heicode-runtime-auth-newapi-secret-design.md index 6a96c13..3a6d152 100644 --- a/docs/heicode-runtime-auth-newapi-secret-design.md +++ b/docs/heicode-runtime-auth-newapi-secret-design.md @@ -71,6 +71,7 @@ Manager 可以有本地 user cache,但 canonical user identity 应来自登录 -> 用户授权或绑定资源 -> Manager 写入 OpenBao -> Manager DB 只保存 secret_ref +-> OpenBao 只允许 Manager/Agnet 受控网络访问,不对公网暴露 高危操作 -> 客户端审批 @@ -81,6 +82,13 @@ Manager 可以有本地 user cache,但 canonical user identity 应来自登录 允许注入子 Agnet 的只能是短期、最小权限、可审计的凭证。长期 Git token、云 access key、SSH 私钥、数据库密码、NewAPI key 原文不得进入 Git、Markdown、普通日志或长期 Agnet 状态。 +公网入口边界: + +- `heicode.xinghanlab.com` 是 Manager、NewAPI 与内部服务的统一公网域名入口。 +- Nginx 可以为 Manager 和 NewAPI 制定路由,例如 Manager 主站与 NewAPI 受控 API 路由。 +- OpenBao 不应作为普通公网路由开放;如果 Manager 已经提供客户端验证、资源绑定、审批和 `secret_ref` 管理接口,客户端不需要直连 OpenBao。 +- OpenBao 访问应限制在容器网络、VM loopback、AKS 内网、Workload Identity 或其它受保护服务间通道。 + 短期凭证注入必须满足: - 有客户端审批记录。 diff --git a/docs/integration/agnet-platform-request-contract.md b/docs/integration/agnet-platform-request-contract.md index 5591c8d..ae33e8b 100644 --- a/docs/integration/agnet-platform-request-contract.md +++ b/docs/integration/agnet-platform-request-contract.md @@ -1,10 +1,12 @@ # Manager → Agnet 平台接口参数文档 -**版本**: v0.2(P1/P5 联调契约) +**版本**: v0.3(P1/P5 联调契约) **生效日期**: 2026-05-03 **状态**: 联调准备;当前仓库提供 Manager 侧最小验证端点,生产 Agnet 平台部署尚未在本文档中宣称完成。 **方向**: Heicode Manager 主动请求 Agnet 平台;Agnet 平台返回部署、日志、监控与审计状态。 **范围**: 创建/停止子 Agent 部署、查询部署、获取事件/日志/监控快照、解析 SK 快照、查询审计日志,以及 Agnet 辅助 NewAPI 重建/部署的参数约定。 + +> 2026-05-04 边界修正:Manager 当前不把 `tenant/project` 作为产品、认证或扣费主轴。新请求应使用 `user_context.user_id`、`user_context.channel_id`、`resource_grants[].binding_scope`、`billing_context(newapi)` 和 `agent_runtime(agnet)`。本文中仍出现的 `tenant_id/project_id` 只表示旧字段兼容或历史接口命名,不应作为新功能设计依据。 **安全红线**: 请求体只允许传资源元数据、权限范围与 `secret_ref`/环境变量名;不得传明文密码、Token、私钥、连接串或云访问密钥。 > 本文档描述 Manager 对 Agnet 平台的出站集成契约。当前仓库中 `/api/agnet/*` 是 Manager 侧最小控制面/模拟端点,用于校验同一套 payload 结构;生产接入时,Manager 应将下列请求发送到 Agnet 平台网关。 @@ -21,7 +23,7 @@ | `POST /api/agnet/deployments/{deployment_id}/stop` | 停止部署或取消排队任务 | 必需 | | `GET /api/agnet/deployments/{deployment_id}/logs` | 拉取部署日志 | 必需 | | `GET /api/agnet/deployments/{deployment_id}/logs/stream` | 实时日志 SSE | 可选 | -| `GET /api/agnet/projects/{project_id}/dashboard-snapshot` | 项目监控快照 | 必需 | +| `GET /api/agnet/projects/{project_id}/dashboard-snapshot` | 资源作用域监控快照;路径名保留旧兼容,参数值按 `binding_scope` 解释 | 必需 | | `GET /api/agnet/deployments/{deployment_id}/metrics` | 单部署指标序列 | 建议 | | `GET /api/agnet/deployments/{deployment_id}/events` | 部署事件 | 必需 | | `GET /api/agnet/audit-logs` | 审计日志 | 必需 | @@ -46,7 +48,7 @@ AGNET_PLATFORM_BASE_URL=https://agnet-platform.example.com ```text AGNET_PLATFORM_BASE_URL=https://staging-agnet.example.com -MANAGER_SERVICE_TOKEN_SECRET_REF=vault://tenant-a/manager/agnet-service-token +MANAGER_SERVICE_TOKEN_SECRET_REF=vault://secret/users/manager-service/bindings/agnet-platform/service-token ``` 完整路径示例: @@ -61,8 +63,8 @@ POST https://agnet-platform.example.com/api/agnet/deployments |---|---:|---| | `Authorization: Bearer ` | 是 | Manager 服务身份令牌,由 Secret Store/运行环境注入。 | | `Content-Type: application/json` | POST/PUT 是 | JSON 请求体。 | -| `X-Tenant-Id: ` | 是 | 租户边界;必须等于 body/query 中 tenant_id。 | -| `X-Project-Id: ` | 建议 | 项目边界,便于平台鉴权与审计。 | +| `X-User-Id: ` | 建议 | 登录用户边界;也可从 Manager 服务端 token 或 body `user_context.user_id` 推导。 | +| `X-Binding-Scope: ` | 建议 | Git/SK/云资源作用域;也可从 `resource_grants[].binding_scope` 推导。 | | `X-Correlation-Id: ` | 是 | Manager 生成,全链路追踪。 | | `X-Request-Id: ` | 建议 | 单次 HTTP 请求追踪 ID,可与 correlation_id 不同。 | | `Idempotency-Key: ` | 创建类接口建议 | 避免重试造成重复部署。 | @@ -99,8 +101,8 @@ POST https://agnet-platform.example.com/api/agnet/deployments | `POLICY_REJECTED` | 缺必填字段、权限策略不满足、风险等级非法。 | | `BUDGET_EXCEEDED` | 超过 token/金额/时长预算。 | | `MODEL_NOT_ALLOWED` | agent 默认模型不在允许列表内。 | -| `FORBIDDEN_CROSS_TENANT` | Header 与 body/query 租户不一致。 | -| `RESOURCE_GRANT_INVALID` | Resource Grant 字段缺失、跨租户/跨项目/角色不匹配。 | +| `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 来源不可解析。 | @@ -116,7 +118,7 @@ POST https://agnet-platform.example.com/api/agnet/deployments |---:|---|---| | 400 | `POLICY_REJECTED` / `RESOURCE_GRANT_INVALID` | 标记部署失败,展示校验原因,不自动重试。 | | 401 | `UNAUTHORIZED` | 检查 Manager 服务令牌的 `secret_ref`/环境注入,不把令牌写入日志。 | -| 403 | `FORBIDDEN_CROSS_TENANT` / `MODEL_NOT_ALLOWED` | 阻断本次部署,写审计事件。 | +| 403 | `FORBIDDEN_SCOPE` / `MODEL_NOT_ALLOWED` | 阻断本次部署,写审计事件。 | | 404 | `NOT_FOUND` | 对查询类接口返回空态;对控制类接口提示资源不存在。 | | 409 | `DEPLOYMENT_CONFLICT` | 使用 `Idempotency-Key` 查询既有结果,避免重复创建。 | | 410 | `CURSOR_EXPIRED` | 丢弃 cursor,使用 `since` 重新拉取。 | @@ -141,19 +143,42 @@ POST /api/agnet/deployments "orchestration_plan": { "intent_id": "intent_20260502_001", "template_hint": "manager-resource-binding", - "objective": "为项目 project-a 启动 builder 子 Agent,允许其读取 SK 并在限定路径内提交代码", + "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": "agnet", + "agents": [ + { + "role": "builder", + "model_ref": "agnet_model_profile_builder", + "instance_count": 1 + } + ] + }, "constraints": { "allowed_model_ids": ["gpt-5.4-mini", "gpt-5.4"] }, "metadata": { - "tenant_id": "tenant-a", - "project_id": "project-a", + "tenant_id": "legacy-user-scope", + "project_id": "legacy-resource-scope", "correlation_id": "corr_20260502_001" }, "agents": [ @@ -175,11 +200,11 @@ POST /api/agnet/deployments ], "runtime_execution": { "profile_id": "aks-codex-standard", - "cloud_principal_refs": ["principal://tenant-a/agnet-runtime"], - "network_policy_ref": "netpol://tenant-a/restricted-egress" + "cloud_principal_refs": ["principal://users/user_123/agnet-runtime"], + "network_policy_ref": "netpol://bindings/repo_default/restricted-egress" }, "sk_access_policy": { - "policy_ref": "sk-policy://tenant-a/default-readonly", + "policy_ref": "sk-policy://bindings/repo_default/default-readonly", "deny_skill_ids": ["dangerous-shell"], "inherit_deployment_defaults": true }, @@ -188,8 +213,10 @@ POST /api/agnet/deployments "grant_id": "grant_git_repo_001", "resource_id": "res_git_repo_001", "resource_type": "git", - "tenant_id": "tenant-a", - "project_id": "project-a", + "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"], @@ -203,7 +230,7 @@ POST /api/agnet/deployments "repo_url": "https://example.com/org/repo.git" }, "status": "active", - "secret_ref": "vault://tenant-a/git/res_git_repo_001", + "secret_ref": "vault://secret/users/user_123/bindings/repo_default/resources/res_git_repo_001", "audit": { "created_by": "manager", "approval_id": "approval_001" @@ -213,8 +240,10 @@ POST /api/agnet/deployments "grant_id": "grant_doc_001", "resource_id": "res_project_doc_001", "resource_type": "project_doc", - "tenant_id": "tenant-a", - "project_id": "project-a", + "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"], @@ -249,12 +278,30 @@ POST /api/agnet/deployments | `budget.max_tokens` | int | 是 | 当前策略上限建议不超过 `500000`。 | | `budget.max_cost_usd` | number | 是 | 当前策略上限建议不超过 `200`。 | | `budget.max_duration_sec` | int | 是 | 当前策略上限建议不超过 `86400`。 | +| `user_context` | object | 建议 | 登录用户上下文,优先使用 Heicode/Agnet 登录返回的 `user.id`/`channelId`。 | +| `billing_context` | object | 条件 | NewAPI 扣费上下文;只表达 user/token/group/quota 映射,不表达子 Agnet 模型或实例数。 | +| `agent_runtime` | object | 条件 | Agnet 平台运行时上下文;表达子 Agnet 角色、模型 profile 和实例数,不承载 NewAPI key 或扣费对象。 | | `constraints.allowed_model_ids` | string[] | 否 | agent 的 `default_model_id` 如填写,必须在此列表内。 | -| `metadata.tenant_id` | string | 是 | 必须与 `X-Tenant-Id` 一致。 | -| `metadata.project_id` | string | 是 | 项目隔离边界。 | +| `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 | 条件 | 当前只允许 `agnet`。 | +| `agent_runtime.agents[].role` | string | 条件 | 必须匹配 `agents[].role_template`。 | +| `agent_runtime.agents[].model_ref` | string | 条件 | Agnet 平台模型 profile 引用;不是 NewAPI 扣费字段。 | +| `agent_runtime.agents[].instance_count` | int | 条件 | 子 Agnet 实例数量,必须大于 0。 | + #### agents[] | 字段 | 类型 | 必填 | 说明 | @@ -294,8 +341,10 @@ POST /api/agnet/deployments | `grant_id` | string | 是 | 授权记录 ID。 | | `resource_id` | string | 是 | Manager 资源 ID。 | | `resource_type` | enum | 是 | `git` / `sk` / `project_doc` / `cloud_account` / `cloud_resource`。 | -| `tenant_id` | string | 是 | 必须等于 orchestration metadata。 | -| `project_id` | string | 是 | 必须等于 orchestration metadata。 | +| `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`。 | @@ -330,8 +379,8 @@ POST /api/agnet/deployments "healthcheck_url_ref": "env://NEWAPI_HEALTHCHECK_URL" }, "metadata": { - "tenant_id": "tenant-a", - "project_id": "newapi-prod", + "tenant_id": "legacy-user-scope", + "project_id": "legacy-newapi-scope", "correlation_id": "corr_newapi_20260503_001", "service": "new-api", "environment": "production", @@ -345,16 +394,18 @@ POST /api/agnet/deployments "default_model_id": "gpt-5.4", "runtime_execution": { "profile_id": "aks-codex-ops", - "cloud_principal_refs": ["principal://tenant-a/agnet-ops"], - "network_policy_ref": "netpol://tenant-a/ops-egress" + "cloud_principal_refs": ["principal://users/user_123/agnet-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", - "tenant_id": "tenant-a", - "project_id": "newapi-prod", + "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"], @@ -368,7 +419,7 @@ POST /api/agnet/deployments "service_name": "new-api" }, "status": "active", - "secret_ref": "vault://tenant-a/cloud/newapi-vm-ops", + "secret_ref": "vault://secret/users/user_123/bindings/newapi-prod/resources/res_newapi_vm", "audit": { "created_by": "manager", "approval_id": "approval_newapi_001" @@ -378,8 +429,10 @@ POST /api/agnet/deployments "grant_id": "grant_newapi_runtime_env", "resource_id": "res_newapi_runtime_env", "resource_type": "cloud_resource", - "tenant_id": "tenant-a", - "project_id": "newapi-prod", + "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"], @@ -388,11 +441,11 @@ POST /api/agnet/deployments "plaintext_export_forbidden": "true" }, "metadata": { - "secret_provider": "vault", + "secret_provider": "openbao", "scope": "newapi-runtime" }, "status": "active", - "secret_ref": "vault://tenant-a/newapi/runtime-env", + "secret_ref": "vault://secret/users/user_123/bindings/newapi-prod/resources/res_newapi_runtime_env", "audit": { "created_by": "manager", "approval_id": "approval_newapi_001" @@ -441,7 +494,7 @@ NewAPI 重建/部署的完成判定必须同时满足: ### 3.1 查询部署列表 ```http -GET /api/agnet/deployments?tenant_id=tenant-a&project_id=project-a +GET /api/agnet/deployments?user_id=user_123&binding_scope=repo_default ``` 返回: @@ -587,7 +640,7 @@ SSE 事件类型: | HTTP | business code | Manager 处理建议 | |---:|---|---| -| 401/403 | `UNAUTHORIZED` / `FORBIDDEN_CROSS_TENANT` | 立即断开流并记录审计。 | +| 401/403 | `UNAUTHORIZED` / `FORBIDDEN_SCOPE` | 立即断开流并记录审计。 | | 404 | `NOT_FOUND` | 停止订阅并刷新部署详情。 | | 429 | `RATE_LIMITED` | 退避后重连,保留 `Last-Event-Id`。 | @@ -598,7 +651,7 @@ SSE 事件类型: ### 5.1 项目监控快照 ```http -GET /api/agnet/projects/{project_id}/dashboard-snapshot?tenant_id=tenant-a&window=1h +GET /api/agnet/projects/{binding_scope}/dashboard-snapshot?window=1h ``` 响应: @@ -686,8 +739,9 @@ GET /api/agnet/deployments/{deployment_id}/events?since=2026-05-02T00:00:00Z&lim "event_id": "evt_001", "event": "deployment.accepted", "schema_version": 1, - "tenant_id": "tenant-a", - "project_id": "project-a", + "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" @@ -714,7 +768,7 @@ GET /api/agnet/deployments/{deployment_id}/events?since=2026-05-02T00:00:00Z&lim ### 6.2 审计日志 ```http -GET /api/agnet/audit-logs?tenant_id=tenant-a&project_id=project-a&actor=agnet_control_plane&action=deployment.accepted&since=2026-05-02T00:00:00Z&limit=200&cursor=cur_001 +GET /api/agnet/audit-logs?user_id=user_123&binding_scope=repo_default&actor=agnet_control_plane&action=deployment.accepted&since=2026-05-02T00:00:00Z&limit=200&cursor=cur_001 ``` 响应: @@ -729,8 +783,9 @@ GET /api/agnet/audit-logs?tenant_id=tenant-a&project_id=project-a&actor=agnet_co "actor": "agnet_control_plane", "action": "deployment.accepted", "resource": "dep_abc123", - "tenant_id": "tenant-a", - "project_id": "project-a", + "user_id": "user_123", + "channel_id": "channel_abc", + "binding_scope": "repo_default", "request_id": "req_001", "correlation_id": "corr_20260502_001", "result": "ok", @@ -772,8 +827,8 @@ POST /api/agnet/sk-snapshots/resolve { "snapshot_id": "sks_001", "deployment_id": "dep_abc123", - "tenant_id": "tenant-a", - "project_id": "project-a", + "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" @@ -794,8 +849,8 @@ Query: | 参数 | 必填 | 说明 | |---|---:|---| -| `tenant_id` | 建议 | 与 `X-Tenant-Id` 一致;平台可从 Header 推导。 | -| `project_id` | 建议 | 项目边界;平台可从 deployment 推导。 | +| `user_id` | 建议 | 与 `X-User-Id` 或 deployment 的 `user_context.user_id` 一致。 | +| `binding_scope` | 建议 | 与 `X-Binding-Scope` 或 deployment 的资源授权作用域一致。 | | `source_type` | 否 | `git` / `upload`,用于筛选。 | | `limit` | 否 | 默认 100,最大 500。 | | `cursor` | 否 | 分页游标。 | @@ -810,11 +865,11 @@ Query: { "snapshot_id": "sks_001", "deployment_id": "dep_abc123", - "tenant_id": "tenant-a", - "project_id": "project-a", + "user_id": "user_123", + "binding_scope": "repo_default", "source_type": "git", "source_ref": "main:skills/heicode/**@sha_xxx", - "artifact_ref": "artifact://tenant-a/sk/sks_001", + "artifact_ref": "artifact://bindings/repo_default/sk/sks_001", "checksum": "sha256:example-redacted", "status": "ready", "resolved_at": "2026-05-02T00:00:00Z" @@ -840,7 +895,7 @@ Query: Manager 发给 Agnet 平台前必须执行: -1. `tenant_id`、`project_id`、`target_role` 与部署计划一致。 +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。 @@ -852,17 +907,17 @@ Manager 发给 Agnet 平台前必须执行: | 对象/接口 | 必填最小集合 | 禁止内容 | |---|---|---| -| `orchestration_plan` | `intent_id`、`template_hint`、`objective`、`risk_level`、`budget`、`metadata.tenant_id`、`metadata.project_id`、`metadata.correlation_id`、`agents[]` | 密钥、连接串、真实主机登录密码、NewAPI key 原文。 | +| `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/Agnet 审计的指令。 | | `runtime_execution` | 任一字段存在时 `profile_id` 必填 | 明文 kubeconfig、SSH key、云访问密钥。 | -| `resource_grants[]` | `grant_id`、`resource_id`、`resource_type`、`tenant_id`、`project_id`、`target_role`、`target_agent_ref`、`permission_scope`、`status` | 明文 `password`、`token`、`private_key`、`access_key`、`credential`、数据库 DSN。 | +| `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://` 引用,不包含真实凭据。 -- `tenant_id`、`project_id` 在 Header、metadata、resource grant 中一致。 +- `user_id` 与 `binding_scope` 在 user context、resource grant、事件和审计中一致。 - `risk_level=high` 的生产运维任务包含 `approval_id` 和回滚引用。 - 日志、事件、监控、审计接口都能通过 `correlation_id` 串联。 - NewAPI 重建/部署只在实际执行并通过健康检查后标记为已部署;未执行时状态只能是 `planned`、`pending_approval`、`accepted` 或 `running`。 @@ -877,7 +932,7 @@ Manager 发给 Agnet 平台前必须执行: | Manager 路由 | 用途 | |---|---| | `POST /api/agnet/deployments` | 校验并接受 orchestration_plan。 | -| `GET /api/agnet/deployments` | 按 tenant/project 查询部署。 | +| `GET /api/agnet/deployments` | 按 user/binding scope 查询部署。 | | `GET /api/agnet/deployments/:deployment_id` | 查询部署详情。 | | `POST /api/agnet/deployments/:deployment_id/stop` | 停止部署。 | | `GET /api/agnet/deployments/:deployment_id/logs` | 查询脱敏日志占位/联调日志。 | @@ -885,7 +940,7 @@ Manager 发给 Agnet 平台前必须执行: | `GET /api/agnet/deployments/:deployment_id/events` | 查询事件。 | | `POST /api/agnet/sk-snapshots/resolve` | 解析 SK 快照。 | | `GET /api/agnet/deployments/:deployment_id/sk-snapshots` | 查询 SK 快照。 | -| `GET /api/agnet/projects/:project_id/dashboard-snapshot` | 项目监控快照。 | +| `GET /api/agnet/projects/:project_id/dashboard-snapshot` | 资源作用域监控快照;路径名保留旧兼容。 | | `GET /api/agnet/audit-logs` | 审计日志。 | 生产对接时,Manager 应把相同契约的请求发送给 Agnet 平台;本地 Manager 端点仅作为最小验证与控制面占位,不代表所有日志/监控平台能力已完整实现。 diff --git a/heicode/controller/agnet_control_plane.go b/heicode/controller/agnet_control_plane.go index 156860a..4482e1a 100644 --- a/heicode/controller/agnet_control_plane.go +++ b/heicode/controller/agnet_control_plane.go @@ -1,6 +1,7 @@ package controller import ( + "fmt" "net/http" "strings" "sync" @@ -33,6 +34,32 @@ type agnetBudget struct { MaxDurationSec int `json:"max_duration_sec"` } +type agnetUserContext struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + ChannelID string `json:"channel_id"` + SubscriptionTier string `json:"subscription_tier"` +} + +type agnetBillingContext struct { + Provider string `json:"provider"` + NewAPIUserRef string `json:"newapi_user_ref"` + NewAPIGroup string `json:"newapi_group"` + QuotaRef string `json:"quota_ref"` +} + +type agnetRuntimeAgent struct { + Role string `json:"role"` + ModelRef string `json:"model_ref"` + InstanceCount int `json:"instance_count"` +} + +type agnetAgentRuntime struct { + Platform string `json:"platform"` + Agents []agnetRuntimeAgent `json:"agents"` +} + // agnetRepoRef matches docs/integration/orchestration-plan-contract.md (git sk_sources). type agnetRepoRef struct { ConnectionID string `json:"connection_id"` @@ -68,8 +95,10 @@ type agnetResourceGrant struct { GrantID string `json:"grant_id"` ResourceID string `json:"resource_id"` ResourceType string `json:"resource_type"` - TenantID string `json:"tenant_id"` - ProjectID string `json:"project_id"` + UserID string `json:"user_id"` + BindingScope string `json:"binding_scope"` + TenantID string `json:"tenant_id,omitempty"` // legacy compatibility only. + ProjectID string `json:"project_id,omitempty"` // legacy compatibility only. TargetRole string `json:"target_role"` TargetAgentRef string `json:"target_agent_ref"` PermissionScope []string `json:"permission_scope"` @@ -95,20 +124,23 @@ type agnetConstraints struct { } type agnetMetadata struct { - TenantID string `json:"tenant_id"` - ProjectID string `json:"project_id"` + TenantID string `json:"tenant_id,omitempty"` // legacy compatibility only. + ProjectID string `json:"project_id,omitempty"` // legacy compatibility only. CorrelationID string `json:"correlation_id"` } type agnetOrchestrationPlan struct { - IntentID string `json:"intent_id"` - TemplateHint string `json:"template_hint"` - Objective string `json:"objective"` - RiskLevel string `json:"risk_level"` - Budget agnetBudget `json:"budget"` - Agents []agnetAgentPlan `json:"agents"` - Constraints agnetConstraints `json:"constraints"` - Metadata agnetMetadata `json:"metadata"` + IntentID string `json:"intent_id"` + TemplateHint string `json:"template_hint"` + Objective string `json:"objective"` + RiskLevel string `json:"risk_level"` + Budget agnetBudget `json:"budget"` + UserContext agnetUserContext `json:"user_context"` + BillingContext agnetBillingContext `json:"billing_context"` + AgentRuntime agnetAgentRuntime `json:"agent_runtime"` + Agents []agnetAgentPlan `json:"agents"` + Constraints agnetConstraints `json:"constraints"` + Metadata agnetMetadata `json:"metadata"` } type agnetDeploymentRequest struct { @@ -128,8 +160,9 @@ type agnetEvent struct { EventID string `json:"event_id"` Event string `json:"event"` SchemaVersion int `json:"schema_version"` - TenantID string `json:"tenant_id"` - ProjectID string `json:"project_id"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + BindingScope string `json:"binding_scope"` DeploymentID string `json:"deployment_id"` CorrelationID string `json:"correlation_id"` OccurredAt string `json:"occurred_at"` @@ -142,8 +175,8 @@ type agnetSKSnapshotResolveRequest struct { type agnetSKSnapshot struct { SnapshotID string `json:"snapshot_id"` DeploymentID string `json:"deployment_id"` - TenantID string `json:"tenant_id"` - ProjectID string `json:"project_id"` + UserID string `json:"user_id"` + BindingScope string `json:"binding_scope"` SourceType string `json:"source_type"` SourceRef string `json:"source_ref"` ResolvedAt string `json:"resolved_at"` @@ -295,6 +328,62 @@ func validateAgentSKAccessPolicy(c *gin.Context, agent agnetAgentPlan) bool { return true } +func validateBillingContext(c *gin.Context, plan agnetOrchestrationPlan) bool { + billing := plan.BillingContext + provider := strings.TrimSpace(strings.ToLower(billing.Provider)) + if provider == "" { + return true + } + if provider != "newapi" { + agnetError(c, "BILLING_CONTEXT_INVALID", "billing_context.provider must be newapi when set") + return false + } + if strings.TrimSpace(plan.UserContext.ChannelID) == "" && + strings.TrimSpace(billing.NewAPIUserRef) == "" && + strings.TrimSpace(billing.NewAPIGroup) == "" && + strings.TrimSpace(billing.QuotaRef) == "" { + agnetError(c, "BILLING_CONTEXT_INVALID", "newapi billing_context requires channel_id, newapi_user_ref, newapi_group, or quota_ref") + return false + } + return true +} + +func validateAgentRuntimeContext(c *gin.Context, plan agnetOrchestrationPlan) bool { + runtime := plan.AgentRuntime + platform := strings.TrimSpace(strings.ToLower(runtime.Platform)) + if platform == "" && len(runtime.Agents) == 0 { + return true + } + if platform != "agnet" { + agnetError(c, "AGENT_RUNTIME_INVALID", "agent_runtime.platform must be agnet when runtime context is present") + return false + } + if len(runtime.Agents) == 0 { + agnetError(c, "AGENT_RUNTIME_INVALID", "agent_runtime.agents must not be empty when runtime context is present") + return false + } + + knownRoles := map[string]bool{} + for _, agent := range plan.Agents { + role := strings.TrimSpace(agent.RoleTemplate) + if role != "" { + knownRoles[role] = true + } + } + for _, agent := range runtime.Agents { + role := strings.TrimSpace(agent.Role) + if role == "" || strings.TrimSpace(agent.ModelRef) == "" || agent.InstanceCount <= 0 { + agnetError(c, "AGENT_RUNTIME_INVALID", "agent_runtime agents require role/model_ref/positive instance_count") + return false + } + if len(knownRoles) > 0 && !knownRoles[role] { + agnetError(c, "AGENT_RUNTIME_INVALID", "agent_runtime agent role must match an orchestration agent role") + return false + } + } + return true +} + func validateResourceGrant(c *gin.Context, plan agnetOrchestrationPlan, agent agnetAgentPlan, grant agnetResourceGrant) bool { resourceType := strings.TrimSpace(grant.ResourceType) switch resourceType { @@ -309,8 +398,16 @@ func validateResourceGrant(c *gin.Context, plan agnetOrchestrationPlan, agent ag agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants require grant_id/resource_id/target_role/target_agent_ref") return false } - if strings.TrimSpace(grant.TenantID) != plan.Metadata.TenantID || strings.TrimSpace(grant.ProjectID) != plan.Metadata.ProjectID { - agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants tenant_id/project_id must match orchestration metadata") + + grantUserID := strings.TrimSpace(grant.UserID) + planUserID := strings.TrimSpace(plan.UserContext.UserID) + bindingScope := strings.TrimSpace(grant.BindingScope) + if bindingScope == "" { + agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants.binding_scope is required") + return false + } + if planUserID != "" && grantUserID != "" && grantUserID != planUserID { + agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants.user_id must match orchestration user_context.user_id") return false } if strings.TrimSpace(grant.TargetRole) != strings.TrimSpace(agent.RoleTemplate) { @@ -349,10 +446,12 @@ func validateOrchestrationPlan(c *gin.Context, plan agnetOrchestrationPlan) bool agnetError(c, "POLICY_REJECTED", "at least one agent is required") return false } - if strings.TrimSpace(plan.Metadata.TenantID) == "" || - strings.TrimSpace(plan.Metadata.ProjectID) == "" || - strings.TrimSpace(plan.Metadata.CorrelationID) == "" { - agnetError(c, "POLICY_REJECTED", "metadata.tenant_id/project_id/correlation_id is required") + if strings.TrimSpace(plan.UserContext.UserID) == "" { + agnetError(c, "POLICY_REJECTED", "user_context.user_id is required") + return false + } + if strings.TrimSpace(plan.Metadata.CorrelationID) == "" { + agnetError(c, "POLICY_REJECTED", "metadata.correlation_id is required") return false } switch plan.RiskLevel { @@ -369,9 +468,10 @@ func validateOrchestrationPlan(c *gin.Context, plan agnetOrchestrationPlan) bool agnetError(c, "BUDGET_EXCEEDED", "budget exceeds current platform policy limits") return false } - - if tenantHeader := strings.TrimSpace(c.GetHeader("X-Tenant-Id")); tenantHeader != "" && tenantHeader != plan.Metadata.TenantID { - agnetError(c, "FORBIDDEN_CROSS_TENANT", "X-Tenant-Id does not match orchestration_plan metadata.tenant_id") + if !validateBillingContext(c, plan) { + return false + } + if !validateAgentRuntimeContext(c, plan) { return false } @@ -407,6 +507,50 @@ func validateOrchestrationPlan(c *gin.Context, plan agnetOrchestrationPlan) bool return true } +func firstPlanBindingScope(plan agnetOrchestrationPlan) string { + for _, agent := range plan.Agents { + for _, grant := range agent.ResourceGrants { + if scope := strings.TrimSpace(grant.BindingScope); scope != "" { + return scope + } + } + } + return "" +} + +func planHasBindingScope(plan agnetOrchestrationPlan, bindingScope string) bool { + bindingScope = strings.TrimSpace(bindingScope) + if bindingScope == "" { + return true + } + for _, agent := range plan.Agents { + for _, grant := range agent.ResourceGrants { + if strings.TrimSpace(grant.BindingScope) == bindingScope { + return true + } + } + } + return false +} + +func applyAuthenticatedManagerUserContext(c *gin.Context, plan *agnetOrchestrationPlan) { + if plan == nil { + return + } + if strings.TrimSpace(plan.UserContext.UserID) == "" { + if userID := c.GetInt("id"); userID > 0 { + plan.UserContext.UserID = fmt.Sprintf("%d", userID) + } + } + if strings.TrimSpace(plan.UserContext.ChannelID) == "" { + if group, ok := c.Get("group"); ok { + if channelID, ok := group.(string); ok { + plan.UserContext.ChannelID = strings.TrimSpace(channelID) + } + } + } +} + func AgnetCreateDeployment(c *gin.Context) { var req agnetDeploymentRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -414,6 +558,7 @@ func AgnetCreateDeployment(c *gin.Context) { return } plan := req.Plan + applyAuthenticatedManagerUserContext(c, &plan) if !validateOrchestrationPlan(c, plan) { return } @@ -432,8 +577,9 @@ func AgnetCreateDeployment(c *gin.Context) { EventID: "evt_" + common.GetUUID()[:12], Event: "deployment.accepted", SchemaVersion: 1, - TenantID: plan.Metadata.TenantID, - ProjectID: plan.Metadata.ProjectID, + UserID: plan.UserContext.UserID, + ChannelID: plan.UserContext.ChannelID, + BindingScope: firstPlanBindingScope(plan), DeploymentID: deploymentID, CorrelationID: plan.Metadata.CorrelationID, OccurredAt: now, @@ -474,16 +620,16 @@ func AgnetGetDeployment(c *gin.Context) { } func AgnetListDeployments(c *gin.Context) { - tenantID := strings.TrimSpace(c.Query("tenant_id")) - projectID := strings.TrimSpace(c.Query("project_id")) + userID := strings.TrimSpace(c.Query("user_id")) + bindingScope := strings.TrimSpace(c.Query("binding_scope")) items := make([]agnetDeploymentRecord, 0) agnetMu.RLock() for _, record := range agnetDeployments { - if tenantID != "" && record.Plan.Metadata.TenantID != tenantID { + if userID != "" && record.Plan.UserContext.UserID != userID { continue } - if projectID != "" && record.Plan.Metadata.ProjectID != projectID { + if bindingScope != "" && !planHasBindingScope(record.Plan, bindingScope) { continue } items = append(items, record) @@ -518,8 +664,9 @@ func AgnetStopDeployment(c *gin.Context) { EventID: "evt_" + common.GetUUID()[:12], Event: "instance.phase_changed", SchemaVersion: 1, - TenantID: record.Plan.Metadata.TenantID, - ProjectID: record.Plan.Metadata.ProjectID, + UserID: record.Plan.UserContext.UserID, + ChannelID: record.Plan.UserContext.ChannelID, + BindingScope: firstPlanBindingScope(record.Plan), DeploymentID: deploymentID, CorrelationID: record.Plan.Metadata.CorrelationID, OccurredAt: agnetNow(), @@ -630,9 +777,9 @@ func AgnetGetDeploymentMetrics(c *gin.Context) { } func AgnetProjectDashboardSnapshot(c *gin.Context) { - projectID := strings.TrimSpace(c.Param("project_id")) - if projectID == "" { - agnetError(c, "POLICY_REJECTED", "project_id is required") + bindingScope := strings.TrimSpace(c.Param("project_id")) + if bindingScope == "" { + agnetError(c, "POLICY_REJECTED", "binding_scope is required") return } @@ -641,7 +788,7 @@ func AgnetProjectDashboardSnapshot(c *gin.Context) { stopped := 0 agnetMu.RLock() for _, record := range agnetDeployments { - if record.Plan.Metadata.ProjectID != projectID { + if !planHasBindingScope(record.Plan, bindingScope) { continue } if record.Status == "accepted" { @@ -657,7 +804,7 @@ func AgnetProjectDashboardSnapshot(c *gin.Context) { agnetMu.RUnlock() common.ApiSuccess(c, gin.H{ - "project_id": projectID, + "binding_scope": bindingScope, "active_instances": active, "phase_distribution": gin.H{"pending": pending, "stopped": stopped}, "failure_rate_1h": 0, @@ -703,8 +850,8 @@ func AgnetResolveSKSnapshots(c *gin.Context) { snapshots = append(snapshots, agnetSKSnapshot{ SnapshotID: "sks_" + common.GetUUID()[:12], DeploymentID: deploymentID, - TenantID: record.Plan.Metadata.TenantID, - ProjectID: record.Plan.Metadata.ProjectID, + UserID: record.Plan.UserContext.UserID, + BindingScope: firstPlanBindingScope(record.Plan), SourceType: sourceType, SourceRef: sourceRef, ResolvedAt: now, @@ -716,8 +863,9 @@ func AgnetResolveSKSnapshots(c *gin.Context) { EventID: "evt_" + common.GetUUID()[:12], Event: "sk_snapshot_refreshed", SchemaVersion: 1, - TenantID: record.Plan.Metadata.TenantID, - ProjectID: record.Plan.Metadata.ProjectID, + UserID: record.Plan.UserContext.UserID, + ChannelID: record.Plan.UserContext.ChannelID, + BindingScope: firstPlanBindingScope(record.Plan), DeploymentID: deploymentID, CorrelationID: record.Plan.Metadata.CorrelationID, OccurredAt: now, @@ -748,19 +896,25 @@ func AgnetListSKSnapshots(c *gin.Context) { } func AgnetListAuditLogs(c *gin.Context) { - projectID := strings.TrimSpace(c.Query("project_id")) + userID := strings.TrimSpace(c.Query("user_id")) + bindingScope := strings.TrimSpace(c.Query("binding_scope")) items := make([]gin.H, 0) agnetMu.RLock() for deploymentID, events := range agnetEvents { for _, event := range events { - if projectID != "" && event.ProjectID != projectID { + if userID != "" && event.UserID != userID { + continue + } + if bindingScope != "" && event.BindingScope != bindingScope { continue } items = append(items, gin.H{ "actor": "agnet_control_plane", "action": event.Event, "resource": deploymentID, - "tenant_id": event.TenantID, + "user_id": event.UserID, + "channel_id": event.ChannelID, + "binding_scope": event.BindingScope, "request_id": agnetRequestID(c), "correlation_id": event.CorrelationID, "result": "ok", diff --git a/heicode/controller/agnet_control_plane_test.go b/heicode/controller/agnet_control_plane_test.go index db5aac7..fa1ab51 100644 --- a/heicode/controller/agnet_control_plane_test.go +++ b/heicode/controller/agnet_control_plane_test.go @@ -43,6 +43,28 @@ func baseAgnetResourceGrantPlan() agnetOrchestrationPlan { MaxCostUSD: 10, MaxDurationSec: 3600, }, + UserContext: agnetUserContext{ + UserID: "user-p1", + Email: "builder@example.invalid", + Role: "user", + ChannelID: "channel-p1", + }, + BillingContext: agnetBillingContext{ + Provider: "newapi", + NewAPIUserRef: "newapi-user-p1", + NewAPIGroup: "development", + QuotaRef: "newapi-quota-ref-p1", + }, + AgentRuntime: agnetAgentRuntime{ + Platform: "agnet", + Agents: []agnetRuntimeAgent{ + { + Role: "builder", + ModelRef: "agnet-model-profile-builder", + InstanceCount: 1, + }, + }, + }, Agents: []agnetAgentPlan{ { RoleTemplate: "builder", @@ -53,8 +75,8 @@ func baseAgnetResourceGrantPlan() agnetOrchestrationPlan { GrantID: "grant-git-builder", ResourceID: "res-git-main", ResourceType: agnetResourceGit, - TenantID: "tenant-p1", - ProjectID: "project-p1", + UserID: "user-p1", + BindingScope: "https://example.invalid/acme/project.git#main", TargetRole: "builder", TargetAgentRef: "agent-builder-1", PermissionScope: []string{"repo:read", "repo:write:feature-branches"}, @@ -67,7 +89,7 @@ func baseAgnetResourceGrantPlan() agnetOrchestrationPlan { "repo_url": "https://example.invalid/acme/project.git", }, Status: agnetGrantStatusActive, - SecretRef: "vault://tenant-p1/project-p1/git/res-git-main", + SecretRef: "vault://secret/users/user-p1/bindings/project-main/resources/res-git-main", Audit: map[string]string{ "created_by": "manager-test", }, @@ -76,8 +98,8 @@ func baseAgnetResourceGrantPlan() agnetOrchestrationPlan { GrantID: "grant-doc-builder", ResourceID: "res-doc-plan", ResourceType: agnetResourceProjectDoc, - TenantID: "tenant-p1", - ProjectID: "project-p1", + UserID: "user-p1", + BindingScope: "docs/heicode.md", TargetRole: "builder", TargetAgentRef: "agent-builder-1", PermissionScope: []string{"doc:read"}, @@ -91,8 +113,6 @@ func baseAgnetResourceGrantPlan() agnetOrchestrationPlan { }, Constraints: agnetConstraints{AllowedModelIDs: []string{"gpt-resource-test"}}, Metadata: agnetMetadata{ - TenantID: "tenant-p1", - ProjectID: "project-p1", CorrelationID: "corr-p1-resource-grant", }, } @@ -109,7 +129,6 @@ func postAgnetCreateDeployment(t *testing.T, plan agnetOrchestrationPlan) (*http ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/deployments", strings.NewReader(string(body))) ctx.Request.Header.Set("Content-Type", "application/json") - ctx.Request.Header.Set("X-Tenant-Id", plan.Metadata.TenantID) AgnetCreateDeployment(ctx) @@ -137,12 +156,16 @@ func TestAgnetCreateDeploymentAcceptsP1ResourceGrantModel(t *testing.T) { grants := stored.Plan.Agents[0].ResourceGrants require.Len(t, grants, 2) require.Equal(t, agnetResourceGit, grants[0].ResourceType) - require.Equal(t, "tenant-p1", grants[0].TenantID) - require.Equal(t, "project-p1", grants[0].ProjectID) + require.Equal(t, "user-p1", grants[0].UserID) + require.Equal(t, "https://example.invalid/acme/project.git#main", grants[0].BindingScope) require.Equal(t, "builder", grants[0].TargetRole) require.Equal(t, "agent-builder-1", grants[0].TargetAgentRef) - require.Equal(t, "vault://tenant-p1/project-p1/git/res-git-main", grants[0].SecretRef) + require.Equal(t, "vault://secret/users/user-p1/bindings/project-main/resources/res-git-main", grants[0].SecretRef) require.Empty(t, grants[1].SecretRef, "project document grants should not require credential material") + require.Equal(t, "newapi", stored.Plan.BillingContext.Provider) + require.Equal(t, "channel-p1", stored.Plan.UserContext.ChannelID) + require.Equal(t, "agnet", stored.Plan.AgentRuntime.Platform) + require.Equal(t, "agnet-model-profile-builder", stored.Plan.AgentRuntime.Agents[0].ModelRef) } func TestAgnetCreateDeploymentRejectsPlaintextResourceGrantCredentialFields(t *testing.T) { @@ -169,10 +192,22 @@ func TestAgnetCreateDeploymentRejectsResourceGrantWithoutSecretRef(t *testing.T) require.Empty(t, agnetDeployments) } -func TestAgnetCreateDeploymentRejectsCrossTenantResourceGrant(t *testing.T) { +func TestAgnetCreateDeploymentRejectsMissingUserContext(t *testing.T) { resetAgnetControlPlaneState(t) plan := baseAgnetResourceGrantPlan() - plan.Agents[0].ResourceGrants[0].TenantID = "tenant-other" + plan.UserContext.UserID = "" + + _, envelope := postAgnetCreateDeployment(t, plan) + + require.False(t, envelope.Success) + require.Equal(t, "POLICY_REJECTED", envelope.Error.Code) + require.Empty(t, agnetDeployments) +} + +func TestAgnetCreateDeploymentRejectsResourceGrantUserMismatch(t *testing.T) { + resetAgnetControlPlaneState(t) + plan := baseAgnetResourceGrantPlan() + plan.Agents[0].ResourceGrants[0].UserID = "user-other" _, envelope := postAgnetCreateDeployment(t, plan) @@ -181,6 +216,45 @@ func TestAgnetCreateDeploymentRejectsCrossTenantResourceGrant(t *testing.T) { require.Empty(t, agnetDeployments) } +func TestAgnetCreateDeploymentRejectsResourceGrantWithoutBindingScope(t *testing.T) { + resetAgnetControlPlaneState(t) + plan := baseAgnetResourceGrantPlan() + plan.Agents[0].ResourceGrants[0].BindingScope = "" + + _, envelope := postAgnetCreateDeployment(t, plan) + + require.False(t, envelope.Success) + require.Equal(t, "RESOURCE_GRANT_INVALID", envelope.Error.Code) + require.Empty(t, agnetDeployments) +} + +func TestAgnetCreateDeploymentRejectsNewAPIBillingWithoutUserMapping(t *testing.T) { + resetAgnetControlPlaneState(t) + plan := baseAgnetResourceGrantPlan() + plan.UserContext.ChannelID = "" + plan.BillingContext.NewAPIUserRef = "" + plan.BillingContext.NewAPIGroup = "" + plan.BillingContext.QuotaRef = "" + + _, envelope := postAgnetCreateDeployment(t, plan) + + require.False(t, envelope.Success) + require.Equal(t, "BILLING_CONTEXT_INVALID", envelope.Error.Code) + require.Empty(t, agnetDeployments) +} + +func TestAgnetCreateDeploymentRejectsRuntimeRoleOutsideAgentPlan(t *testing.T) { + resetAgnetControlPlaneState(t) + plan := baseAgnetResourceGrantPlan() + plan.AgentRuntime.Agents[0].Role = "ops" + + _, envelope := postAgnetCreateDeployment(t, plan) + + require.False(t, envelope.Success) + require.Equal(t, "AGENT_RUNTIME_INVALID", envelope.Error.Code) + require.Empty(t, agnetDeployments) +} + func TestAgnetDeploymentLogsAndMetricsExposeRedactedReadiness(t *testing.T) { resetAgnetControlPlaneState(t) diff --git a/heicode/controller/resource.go b/heicode/controller/resource.go index d71c8f3..4b1e35e 100644 --- a/heicode/controller/resource.go +++ b/heicode/controller/resource.go @@ -48,6 +48,7 @@ var secretLikeKeys = map[string]bool{ type resourcePayload struct { TenantId string `json:"tenant_id"` ProjectId string `json:"project_id"` + BindingScope string `json:"binding_scope"` Name string `json:"name"` ResourceType string `json:"resource_type"` Provider string `json:"provider"` @@ -68,6 +69,7 @@ type resourceResponse struct { UserId int `json:"user_id"` TenantId string `json:"tenant_id"` ProjectId string `json:"project_id"` + BindingScope string `json:"binding_scope"` Name string `json:"name"` ResourceType string `json:"resource_type"` Provider string `json:"provider"` @@ -84,6 +86,7 @@ type resourceResponse struct { type resourceGrantPayload struct { TenantId string `json:"tenant_id"` ProjectId string `json:"project_id"` + BindingScope string `json:"binding_scope"` ResourceId int `json:"resource_id"` Role string `json:"role"` AgnetId string `json:"agnet_id"` @@ -97,6 +100,7 @@ type resourceGrantResponse struct { UserId int `json:"user_id"` TenantId string `json:"tenant_id"` ProjectId string `json:"project_id"` + BindingScope string `json:"binding_scope"` ResourceId int `json:"resource_id"` Role string `json:"role"` AgnetId string `json:"agnet_id"` @@ -112,6 +116,7 @@ type resourceGrantResponse struct { func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) { p.TenantId = strings.TrimSpace(p.TenantId) p.ProjectId = strings.TrimSpace(p.ProjectId) + p.BindingScope = strings.TrimSpace(p.BindingScope) p.Name = strings.TrimSpace(p.Name) p.ResourceType = strings.ToLower(strings.TrimSpace(p.ResourceType)) p.Provider = strings.TrimSpace(p.Provider) @@ -119,8 +124,8 @@ func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) { p.SecretRef = strings.TrimSpace(p.SecretRef) p.Status = strings.ToLower(strings.TrimSpace(p.Status)) - if p.TenantId == "" { - return p, errors.New("tenant_id required") + if p.BindingScope == "" { + p.BindingScope = inferResourceBindingScope(p) } if p.Name == "" { return p, errors.New("name required") @@ -155,16 +160,11 @@ func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) { func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload, error) { p.TenantId = strings.TrimSpace(p.TenantId) p.ProjectId = strings.TrimSpace(p.ProjectId) + p.BindingScope = strings.TrimSpace(p.BindingScope) p.Role = strings.TrimSpace(p.Role) p.AgnetId = strings.TrimSpace(p.AgnetId) p.Status = strings.ToLower(strings.TrimSpace(p.Status)) - if p.TenantId == "" { - return p, errors.New("tenant_id required") - } - if p.ProjectId == "" { - return p, errors.New("project_id required") - } if p.ResourceId <= 0 { return p, errors.New("resource_id required") } @@ -192,6 +192,21 @@ func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload return p, nil } +func inferResourceBindingScope(p resourcePayload) string { + switch { + case p.ExternalId != "": + return p.ExternalId + case p.ProjectId != "": + return p.ProjectId + case p.TenantId != "": + return p.TenantId + case p.Name != "": + return p.Name + default: + return "resource" + } +} + func sortedResourceTypes() []string { types := make([]string, 0, len(allowedResourceTypes)) for resourceType := range allowedResourceTypes { @@ -260,6 +275,7 @@ func resourceToResponse(resource model.ResourceBinding) resourceResponse { UserId: resource.UserId, TenantId: resource.TenantId, ProjectId: resource.ProjectId, + BindingScope: resource.BindingScope, Name: resource.Name, ResourceType: resource.ResourceType, Provider: resource.Provider, @@ -280,6 +296,7 @@ func resourceGrantToResponse(grant model.ResourceGrant, resource *model.Resource UserId: grant.UserId, TenantId: grant.TenantId, ProjectId: grant.ProjectId, + BindingScope: grant.BindingScope, ResourceId: grant.ResourceId, Role: grant.Role, AgnetId: grant.AgnetId, @@ -306,6 +323,9 @@ func ListResources(c *gin.Context) { if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" { query = query.Where("project_id = ?", projectId) } + if bindingScope := strings.TrimSpace(c.Query("binding_scope")); bindingScope != "" { + query = query.Where("binding_scope = ?", bindingScope) + } if resourceType := strings.TrimSpace(c.Query("resource_type")); resourceType != "" { query = query.Where("resource_type = ?", strings.ToLower(resourceType)) } @@ -342,6 +362,7 @@ func CreateResource(c *gin.Context) { UserId: userId, TenantId: payload.TenantId, ProjectId: payload.ProjectId, + BindingScope: payload.BindingScope, Name: payload.Name, ResourceType: payload.ResourceType, Provider: payload.Provider, @@ -387,6 +408,7 @@ func UpdateResource(c *gin.Context) { } resource.TenantId = payload.TenantId resource.ProjectId = payload.ProjectId + resource.BindingScope = payload.BindingScope resource.Name = payload.Name resource.ResourceType = payload.ResourceType resource.Provider = payload.Provider @@ -459,15 +481,18 @@ func UpsertResourceSecret(c *gin.Context) { } func resourceSecretPath(resource model.ResourceBinding) string { - projectId := resource.ProjectId - if strings.TrimSpace(projectId) == "" { - projectId = "_tenant" + scope := resource.BindingScope + if strings.TrimSpace(scope) == "" { + scope = resource.ExternalId + } + if strings.TrimSpace(scope) == "" { + scope = resource.Name } return strings.Join([]string{ - "tenants", - sanitizeSecretPathSegment(resource.TenantId), - "projects", - sanitizeSecretPathSegment(projectId), + "users", + fmt.Sprintf("%d", resource.UserId), + "bindings", + sanitizeSecretPathSegment(scope), "resources", fmt.Sprintf("%d", resource.Id), }, "/") @@ -521,6 +546,9 @@ func ListResourceGrants(c *gin.Context) { if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" { query = query.Where("project_id = ?", projectId) } + if bindingScope := strings.TrimSpace(c.Query("binding_scope")); bindingScope != "" { + query = query.Where("binding_scope = ?", bindingScope) + } if agnetId := strings.TrimSpace(c.Query("agnet_id")); agnetId != "" { query = query.Where("agnet_id = ?", agnetId) } @@ -548,11 +576,12 @@ func CreateResourceGrant(c *gin.Context) { common.ApiError(c, err) return } - resource, err := findGrantResource(userId, payload.ResourceId, payload.TenantId) + resource, err := findGrantResource(userId, payload.ResourceId, payload.BindingScope) if err != nil { common.ApiError(c, err) return } + payload = inheritResourceGrantScope(payload, resource) permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload) if err != nil { common.ApiError(c, err) @@ -562,6 +591,7 @@ func CreateResourceGrant(c *gin.Context) { UserId: userId, TenantId: payload.TenantId, ProjectId: payload.ProjectId, + BindingScope: payload.BindingScope, ResourceId: payload.ResourceId, Role: payload.Role, AgnetId: payload.AgnetId, @@ -597,11 +627,12 @@ func UpdateResourceGrant(c *gin.Context) { common.ApiError(c, err) return } - resource, err := findGrantResource(userId, payload.ResourceId, payload.TenantId) + resource, err := findGrantResource(userId, payload.ResourceId, payload.BindingScope) if err != nil { common.ApiError(c, err) return } + payload = inheritResourceGrantScope(payload, resource) permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload) if err != nil { common.ApiError(c, err) @@ -609,6 +640,7 @@ func UpdateResourceGrant(c *gin.Context) { } grant.TenantId = payload.TenantId grant.ProjectId = payload.ProjectId + grant.BindingScope = payload.BindingScope grant.ResourceId = payload.ResourceId grant.Role = payload.Role grant.AgnetId = payload.AgnetId @@ -654,7 +686,20 @@ func marshalResourceGrantPayloadJSON(payload resourceGrantPayload) (string, stri return permissionScope, constraints, nil } -func findGrantResource(userId int, resourceId int, tenantId string) (model.ResourceBinding, error) { +func inheritResourceGrantScope(payload resourceGrantPayload, resource model.ResourceBinding) resourceGrantPayload { + if payload.BindingScope == "" { + payload.BindingScope = resource.BindingScope + } + if payload.TenantId == "" { + payload.TenantId = resource.TenantId + } + if payload.ProjectId == "" { + payload.ProjectId = resource.ProjectId + } + return payload +} + +func findGrantResource(userId int, resourceId int, bindingScope string) (model.ResourceBinding, error) { var resource model.ResourceBinding if err := model.DB.Where("id = ? AND user_id = ?", resourceId, userId).First(&resource).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -662,8 +707,8 @@ func findGrantResource(userId int, resourceId int, tenantId string) (model.Resou } return resource, err } - if resource.TenantId != tenantId { - return resource, errors.New("resource tenant_id does not match grant tenant_id") + if bindingScope != "" && resource.BindingScope != "" && resource.BindingScope != bindingScope { + return resource, errors.New("resource binding_scope does not match grant binding_scope") } return resource, nil } diff --git a/heicode/controller/resource_test.go b/heicode/controller/resource_test.go index e41cfdf..a03e835 100644 --- a/heicode/controller/resource_test.go +++ b/heicode/controller/resource_test.go @@ -60,13 +60,12 @@ func performResourceRequestWithRoute(handler gin.HandlerFunc, userID int, method func TestCreateResourceStoresMetadataAndSecretRefOnly(t *testing.T) { db := setupResourceControllerTestDB(t) body := `{ - "tenant_id":"tenant-a", - "project_id":"project-a", + "binding_scope":"https://example.com/org/repo#main", "name":"Project repository", "resource_type":"git", "provider":"github", "external_id":"https://example.com/org/repo", - "secret_ref":"vault://tenant-a/git/repo", + "secret_ref":"vault://secret/resources/repo", "metadata":{"repo_url":"https://example.com/org/repo","ref":"main","allowed_paths":["."]}, "permission_scope":{"actions":["read","write"]}, "constraints":{"environment":"dev"} @@ -75,12 +74,14 @@ func TestCreateResourceStoresMetadataAndSecretRefOnly(t *testing.T) { w := performResourceRequest(CreateResource, 7, http.MethodPost, "/", body) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Body.String(), `"success":true`) - require.Contains(t, w.Body.String(), `"secret_ref":"vault://tenant-a/git/repo"`) + require.Contains(t, w.Body.String(), `"binding_scope":"https://example.com/org/repo#main"`) + require.Contains(t, w.Body.String(), `"secret_ref":"vault://secret/resources/repo"`) var resource model.ResourceBinding require.NoError(t, db.First(&resource).Error) require.Equal(t, "git", resource.ResourceType) - require.Equal(t, "vault://tenant-a/git/repo", resource.SecretRef) + require.Equal(t, "https://example.com/org/repo#main", resource.BindingScope) + require.Equal(t, "vault://secret/resources/repo", resource.SecretRef) require.NotContains(t, resource.Metadata, "token") require.NotContains(t, resource.PermissionScope, "token") require.NotContains(t, resource.Constraints, "token") @@ -89,11 +90,10 @@ func TestCreateResourceStoresMetadataAndSecretRefOnly(t *testing.T) { func TestCreateResourceRejectsPlaintextSecretKeys(t *testing.T) { setupResourceControllerTestDB(t) body := `{ - "tenant_id":"tenant-a", "name":"Cloud account", "resource_type":"cloud_account", "metadata":{"account_id":"sub-1","access_key":"do-not-store"}, - "secret_ref":"vault://tenant-a/cloud/sub-1" + "secret_ref":"vault://secret/cloud/sub-1" }` w := performResourceRequest(CreateResource, 7, http.MethodPost, "/", body) @@ -102,24 +102,22 @@ func TestCreateResourceRejectsPlaintextSecretKeys(t *testing.T) { require.Contains(t, w.Body.String(), "plaintext secrets are not allowed") } -func TestCreateResourceGrantAssignsTenantProjectRoleAgnet(t *testing.T) { +func TestCreateResourceGrantAssignsBoundResourceToRoleAgnet(t *testing.T) { db := setupResourceControllerTestDB(t) resource := model.ResourceBinding{ UserId: 7, - TenantId: "tenant-a", - ProjectId: "project-a", + BindingScope: "https://example.com/sk.git#main", Name: "SK repo", ResourceType: "sk", Provider: "git", - SecretRef: "vault://tenant-a/sk/repo", + SecretRef: "vault://secret/resources/sk-repo", Metadata: `{"repo_url":"https://example.com/sk.git"}`, Status: "active", } require.NoError(t, db.Create(&resource).Error) body := fmt.Sprintf(`{ - "tenant_id":"tenant-a", - "project_id":"project-a", + "binding_scope":"https://example.com/sk.git#main", "resource_id":%d, "role":"developer", "agnet_id":"agnet-dev-1", @@ -130,25 +128,23 @@ func TestCreateResourceGrantAssignsTenantProjectRoleAgnet(t *testing.T) { w := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", body) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Body.String(), `"success":true`) - require.Contains(t, w.Body.String(), `"tenant_id":"tenant-a"`) - require.Contains(t, w.Body.String(), `"project_id":"project-a"`) + require.Contains(t, w.Body.String(), `"binding_scope":"https://example.com/sk.git#main"`) require.Contains(t, w.Body.String(), `"role":"developer"`) require.Contains(t, w.Body.String(), `"agnet_id":"agnet-dev-1"`) var grant model.ResourceGrant require.NoError(t, db.First(&grant).Error) require.Equal(t, resource.Id, grant.ResourceId) - require.Equal(t, "tenant-a", grant.TenantId) - require.Equal(t, "project-a", grant.ProjectId) + require.Equal(t, "https://example.com/sk.git#main", grant.BindingScope) require.Equal(t, "developer", grant.Role) require.Equal(t, "agnet-dev-1", grant.AgnetId) } -func TestCreateResourceGrantRejectsCrossTenantResource(t *testing.T) { +func TestCreateResourceGrantRejectsMismatchedBindingScope(t *testing.T) { db := setupResourceControllerTestDB(t) resource := model.ResourceBinding{ UserId: 7, - TenantId: "tenant-a", + BindingScope: "azure-vm-prod", Name: "VM", ResourceType: "cloud_resource", Status: "active", @@ -156,8 +152,7 @@ func TestCreateResourceGrantRejectsCrossTenantResource(t *testing.T) { require.NoError(t, db.Create(&resource).Error) body := fmt.Sprintf(`{ - "tenant_id":"tenant-b", - "project_id":"project-a", + "binding_scope":"azure-vm-dev", "resource_id":%d, "role":"operator", "agnet_id":"agnet-ops-1" @@ -166,15 +161,14 @@ func TestCreateResourceGrantRejectsCrossTenantResource(t *testing.T) { w := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", body) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Body.String(), `"success":false`) - require.Contains(t, w.Body.String(), "resource tenant_id does not match grant tenant_id") + require.Contains(t, w.Body.String(), "resource binding_scope does not match grant binding_scope") } func TestUpsertResourceSecretWritesOpenBaoAndStoresOnlySecretRef(t *testing.T) { db := setupResourceControllerTestDB(t) resource := model.ResourceBinding{ UserId: 7, - TenantId: "tenant-a", - ProjectId: "project-a", + BindingScope: "github-org-repo-main", Name: "GitHub", ResourceType: "git", Provider: "github", @@ -186,7 +180,7 @@ func TestUpsertResourceSecretWritesOpenBaoAndStoresOnlySecretRef(t *testing.T) { var writtenBody map[string]map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPost, r.Method) - require.Equal(t, "/v1/secret/data/tenants/tenant-a/projects/project-a/resources/1", r.URL.Path) + require.Equal(t, "/v1/secret/data/users/7/bindings/github-org-repo-main/resources/1", r.URL.Path) require.Equal(t, "manager-token", r.Header.Get("X-Vault-Token")) require.NoError(t, common.DecodeJson(r.Body, &writtenBody)) writtenPath = r.URL.Path @@ -202,14 +196,14 @@ func TestUpsertResourceSecretWritesOpenBaoAndStoresOnlySecretRef(t *testing.T) { w := performResourceRequestWithRoute(UpsertResourceSecret, 7, http.MethodPost, "/:id/secret", "/1/secret", body) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Body.String(), `"success":true`) - require.Contains(t, w.Body.String(), `"secret_ref":"vault://secret/tenants/tenant-a/projects/project-a/resources/1"`) + require.Contains(t, w.Body.String(), `"secret_ref":"vault://secret/users/7/bindings/github-org-repo-main/resources/1"`) require.NotContains(t, w.Body.String(), "do-not-echo") - require.Equal(t, "/v1/secret/data/tenants/tenant-a/projects/project-a/resources/1", writtenPath) + require.Equal(t, "/v1/secret/data/users/7/bindings/github-org-repo-main/resources/1", writtenPath) require.Equal(t, "do-not-echo", writtenBody["data"]["access_token"]) var stored model.ResourceBinding require.NoError(t, db.First(&stored, resource.Id).Error) - require.Equal(t, "vault://secret/tenants/tenant-a/projects/project-a/resources/1", stored.SecretRef) + require.Equal(t, "vault://secret/users/7/bindings/github-org-repo-main/resources/1", stored.SecretRef) require.NotContains(t, stored.Metadata, "do-not-echo") } @@ -217,7 +211,7 @@ func TestUpsertResourceSecretRequiresSecretStoreToken(t *testing.T) { db := setupResourceControllerTestDB(t) resource := model.ResourceBinding{ UserId: 7, - TenantId: "tenant-a", + BindingScope: "github", Name: "GitHub", ResourceType: "git", Status: "active", diff --git a/heicode/model/resource.go b/heicode/model/resource.go index 13695d6..e8f1d76 100644 --- a/heicode/model/resource.go +++ b/heicode/model/resource.go @@ -1,13 +1,14 @@ package model // ResourceBinding is the Manager-side resource record described by docs/plan.md P1. -// It stores tenant/project resource metadata and a Secret Store reference only; +// It stores user-owned resource metadata and a Secret Store reference only; // plaintext credentials must never be stored here. type ResourceBinding struct { Id int `json:"id"` UserId int `json:"user_id" gorm:"index;not null"` - TenantId string `json:"tenant_id" gorm:"type:varchar(64);index;not null"` + TenantId string `json:"tenant_id" gorm:"type:varchar(64);index"` // legacy compatibility only; do not use as a product boundary. ProjectId string `json:"project_id" gorm:"type:varchar(64);index"` + BindingScope string `json:"binding_scope" gorm:"type:varchar(512);index"` Name string `json:"name" gorm:"type:varchar(128);not null"` ResourceType string `json:"resource_type" gorm:"type:varchar(32);index;not null"` Provider string `json:"provider" gorm:"type:varchar(64);default:'custom'"` @@ -21,13 +22,14 @@ type ResourceBinding struct { UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"` } -// ResourceGrant assigns a ResourceBinding to a project, role, and child Agnet. -// It is the auditable Manager expression of "tenant/project grants resource to role". +// ResourceGrant assigns a ResourceBinding to a role and child Agnet. +// It is the auditable Manager expression of "user grants bound resource to role". type ResourceGrant struct { Id int `json:"id"` UserId int `json:"user_id" gorm:"index;not null"` - TenantId string `json:"tenant_id" gorm:"type:varchar(64);index;not null"` - ProjectId string `json:"project_id" gorm:"type:varchar(64);index;not null"` + TenantId string `json:"tenant_id" gorm:"type:varchar(64);index"` // legacy compatibility only; do not use as a product boundary. + ProjectId string `json:"project_id" gorm:"type:varchar(64);index"` + BindingScope string `json:"binding_scope" gorm:"type:varchar(512);index"` ResourceId int `json:"resource_id" gorm:"index;not null"` Role string `json:"role" gorm:"type:varchar(128);index;not null"` AgnetId string `json:"agnet_id" gorm:"type:varchar(128);index;not null"` diff --git a/heicode/web/default/src/features/agnet-console/api.ts b/heicode/web/default/src/features/agnet-console/api.ts index 3c54840..423d65a 100644 --- a/heicode/web/default/src/features/agnet-console/api.ts +++ b/heicode/web/default/src/features/agnet-console/api.ts @@ -37,6 +37,15 @@ export type AgnetAgentPlan = { sk_sources?: AgnetSKSource[] runtime_execution?: AgnetRuntimeExecution sk_access_policy?: AgnetSKAccessPolicy + resource_grants?: Array<{ + grant_id?: string + resource_id?: string + resource_type?: string + user_id?: string + binding_scope?: string + target_role?: string + target_agent_ref?: string + }> } export type AgnetBudget = { @@ -45,12 +54,45 @@ export type AgnetBudget = { max_duration_sec: number } +export type AgnetUserContext = { + user_id: string + email?: string + role?: string + channel_id?: string + subscription_tier?: string +} + +export type AgnetBillingContext = { + provider?: 'newapi' + newapi_user_ref?: string + newapi_group?: string + quota_ref?: string +} + +export type AgnetAgentRuntime = { + platform?: 'agnet' + agents?: Array<{ + role: string + model_ref: string + instance_count: number + }> +} + export type AgnetConstraints = { + /** Runtime model allow-list for Agnet deployments; not a NewAPI billing map. */ allowed_model_ids?: string[] } export type AgnetOrchestrationMetadata = { + /** + * Compatibility field for Agnet routing scope; UI treats this as user scope, + * not billing tenant. + */ tenant_id: string + /** + * Compatibility field for Agnet routing scope; UI treats this as resource + * scope, not project control. + */ project_id: string correlation_id: string } @@ -61,6 +103,9 @@ export type AgnetOrchestrationPlan = { objective: string risk_level: 'low' | 'medium' | 'high' budget: AgnetBudget + user_context: AgnetUserContext + billing_context?: AgnetBillingContext + agent_runtime?: AgnetAgentRuntime agents: AgnetAgentPlan[] constraints: AgnetConstraints metadata: AgnetOrchestrationMetadata @@ -73,7 +118,11 @@ export type AgnetCreateDeploymentBody = { export type AgnetCreateDeploymentResult = { deployment_id: string status: string - agent_instances?: Array<{ instance_id?: string; role?: string; phase?: string }> + agent_instances?: Array<{ + instance_id?: string + role?: string + phase?: string + }> } export type AgnetDeployment = { @@ -161,35 +210,33 @@ export async function createAgnetDeployment( } export async function getAgnetDeploymentEvents(deploymentId: string) { - const res = await api.get> }>>( - `/api/agnet/deployments/${deploymentId}/events` - ) + const res = await api.get< + ApiEnvelope<{ items?: Array> }> + >(`/api/agnet/deployments/${deploymentId}/events`) return res.data?.data?.items ?? [] } export async function getAgnetAuditLogs() { - const res = await api.get> }>>( - '/api/agnet/audit-logs' - ) + const res = await api.get< + ApiEnvelope<{ items?: Array> }> + >('/api/agnet/audit-logs') return res.data?.data?.items ?? [] } export async function getAgnetSnapshots(deploymentId: string) { - const res = await api.get> }>>( - `/api/agnet/deployments/${deploymentId}/sk-snapshots`, - { - skipBusinessError: true, - skipErrorHandler: true, - } as Record - ) + const res = await api.get< + ApiEnvelope<{ items?: Array> }> + >(`/api/agnet/deployments/${deploymentId}/sk-snapshots`, { + skipBusinessError: true, + skipErrorHandler: true, + } as Record) if (!res.data?.success) return [] return res.data?.data?.items ?? [] } export async function listGitSources(): Promise { - const res = await api.get>( - '/api/git-sources/' - ) + const res = + await api.get>('/api/git-sources/') return res.data?.data?.items ?? [] } diff --git a/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx b/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx index 2236105..1180ac1 100644 --- a/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx +++ b/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' +import { useAuthStore } from '@/stores/auth-store' import { createAgnetDeployment, type AgnetAgentPlan, @@ -64,8 +65,7 @@ function buildAgent(row: AgentFormRow): AgnetAgentPlan { const profileId = row.profile_id.trim() const net = row.network_policy_ref.trim() const clouds = splitComma(row.cloud_principals) - const hasRuntime = - profileId !== '' || net !== '' || clouds.length > 0 + const hasRuntime = profileId !== '' || net !== '' || clouds.length > 0 if (hasRuntime && profileId === '') { throw new Error('profile_required_for_runtime') } @@ -135,6 +135,7 @@ export function CreateAgnetDeploymentSheet({ }) { const { t } = useTranslation() const queryClient = useQueryClient() + const currentUser = useAuthStore((state) => state.auth.user) const [templateHint, setTemplateHint] = useState('agile_min') const [objective, setObjective] = useState('') @@ -144,8 +145,8 @@ export function CreateAgnetDeploymentSheet({ const [maxTokens, setMaxTokens] = useState(120_000) const [maxCost, setMaxCost] = useState(8) const [maxDurationSec, setMaxDurationSec] = useState(3600) - const [tenantId, setTenantId] = useState('ten_local') - const [projectId, setProjectId] = useState('prj_default') + const [userScopeRef, setUserScopeRef] = useState('user_local') + const [resourceScopeRef, setResourceScopeRef] = useState('repo_default') const [correlationId, setCorrelationId] = useState('') const [allowedModels, setAllowedModels] = useState('') const [agents, setAgents] = useState([emptyAgent()]) @@ -163,8 +164,8 @@ export function CreateAgnetDeploymentSheet({ setMaxTokens(120_000) setMaxCost(8) setMaxDurationSec(3600) - setTenantId('ten_local') - setProjectId('prj_default') + setUserScopeRef('user_local') + setResourceScopeRef('repo_default') setCorrelationId(crypto.randomUUID()) setAllowedModels('') setAgents([emptyAgent()]) @@ -183,6 +184,13 @@ export function CreateAgnetDeploymentSheet({ } throw e } + const runtimeAgents = agentPlans + .filter((agent) => agent.role_template && agent.default_model_id) + .map((agent) => ({ + role: agent.role_template, + model_ref: agent.default_model_id as string, + instance_count: 1, + })) const plan: AgnetOrchestrationPlan = { intent_id: intentId, @@ -194,13 +202,32 @@ export function CreateAgnetDeploymentSheet({ max_cost_usd: maxCost, max_duration_sec: maxDurationSec, }, + user_context: { + user_id: String(currentUser?.id || userScopeRef.trim()), + email: currentUser?.email || undefined, + role: String(currentUser?.role || 'user'), + channel_id: currentUser?.group || undefined, + }, + billing_context: currentUser?.group + ? { + provider: 'newapi', + newapi_group: currentUser.group, + } + : undefined, + agent_runtime: + runtimeAgents.length > 0 + ? { + platform: 'agnet', + agents: runtimeAgents, + } + : undefined, agents: agentPlans, constraints: { allowed_model_ids: allowed.length > 0 ? allowed : undefined, }, metadata: { - tenant_id: tenantId.trim(), - project_id: projectId.trim(), + tenant_id: userScopeRef.trim(), + project_id: resourceScopeRef.trim(), correlation_id: correlationId.trim(), }, } @@ -239,16 +266,16 @@ export function CreateAgnetDeploymentSheet({ return ( templateHint.trim() && objective.trim() && - tenantId.trim() && - projectId.trim() && + userScopeRef.trim() && + resourceScopeRef.trim() && correlationId.trim() && agents.every((a) => a.role_template.trim() && a.goal.trim()) ) }, [ templateHint, objective, - tenantId, - projectId, + userScopeRef, + resourceScopeRef, correlationId, agents, ]) @@ -355,18 +382,18 @@ export function CreateAgnetDeploymentSheet({

- + setTenantId(e.target.value)} + value={userScopeRef} + onChange={(e) => setUserScopeRef(e.target.value)} className='mt-1 font-mono text-xs' />
- + setProjectId(e.target.value)} + value={resourceScopeRef} + onChange={(e) => setResourceScopeRef(e.target.value)} className='mt-1 font-mono text-xs' />
@@ -380,7 +407,9 @@ export function CreateAgnetDeploymentSheet({ />
- + setAllowedModels(e.target.value)} @@ -460,7 +489,7 @@ export function CreateAgnetDeploymentSheet({ className='text-sm' /> setAgents((prev) => { diff --git a/heicode/web/default/src/features/agnet-console/pages.tsx b/heicode/web/default/src/features/agnet-console/pages.tsx index 9280231..71a8180 100644 --- a/heicode/web/default/src/features/agnet-console/pages.tsx +++ b/heicode/web/default/src/features/agnet-console/pages.tsx @@ -110,7 +110,12 @@ function StatusBadge({ phase }: { phase: string }) { ) } -function PageSurface(props: { title: string; subtitle?: string; toolbar?: React.ReactNode; children: React.ReactNode }) { +function PageSurface(props: { + title: string + subtitle?: string + toolbar?: React.ReactNode + children: React.ReactNode +}) { return (
@@ -120,11 +125,15 @@ function PageSurface(props: { title: string; subtitle?: string; toolbar?: React.

{props.title}

{props.subtitle && ( -

{props.subtitle}

+

+ {props.subtitle} +

)}
{props.toolbar && ( -
{props.toolbar}
+
+ {props.toolbar} +
)} {props.children} @@ -144,7 +153,13 @@ function EmptySurface(props: { title: string; hint?: string }) { ) } -function LoadingGrid({ rows = 4, height = 'h-24' }: { rows?: number; height?: string }) { +function LoadingGrid({ + rows = 4, + height = 'h-24', +}: { + rows?: number + height?: string +}) { return (
{Array.from({ length: rows }).map((_, idx) => ( @@ -154,7 +169,15 @@ function LoadingGrid({ rows = 4, height = 'h-24' }: { rows?: number; height?: st ) } -function MetaPill({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) { +function MetaPill({ + icon: Icon, + label, + value, +}: { + icon: React.ComponentType<{ className?: string }> + label: string + value: string +}) { return ( @@ -166,7 +189,10 @@ function MetaPill({ icon: Icon, label, value }: { icon: React.ComponentType<{ cl ) } -function describeRiskLevel(dep: AgnetDeployment): { label: string; tone: 'low' | 'mid' | 'high' } { +function describeRiskLevel(dep: AgnetDeployment): { + label: string + tone: 'low' | 'mid' | 'high' +} { const objective = (dep.orchestration_plan?.objective || '').toLowerCase() if (objective.includes('production') || objective.includes('critical')) { return { label: 'high', tone: 'high' } @@ -183,8 +209,15 @@ function describeBudget(dep: AgnetDeployment): string { return `${agents} agents` } -function describeExecutor(dep: AgnetDeployment): string { - return dep.orchestration_plan?.metadata?.tenant_id || '—' +function describeScope(dep: AgnetDeployment): string { + const firstGrant = dep.orchestration_plan?.agents?.flatMap( + (agent) => agent.resource_grants || [] + )[0] + return ( + firstGrant?.binding_scope || + dep.orchestration_plan?.metadata?.tenant_id || + '—' + ) } function formatRelativeTime(value: string | undefined): string { @@ -227,6 +260,11 @@ export function AgnetDeploymentsPage() { const blob = `${dep.deployment_id} ${dep.orchestration_plan?.objective || ''} ${ dep.orchestration_plan?.metadata?.tenant_id || '' + } ${ + dep.orchestration_plan?.agents + ?.flatMap((agent) => agent.resource_grants || []) + .map((grant) => grant.binding_scope || '') + .join(' ') || '' }`.toLowerCase() if (!blob.includes(k)) return false } @@ -236,123 +274,126 @@ export function AgnetDeploymentsPage() { return ( <> - - -
- - setKeyword(e.target.value)} - placeholder={t('Find deployment / tenant / objective')} - className='h-9 w-64 rounded-xl pl-8 text-xs' - /> -
- - - } - > - {isLoading ? ( - - ) : filtered.length === 0 ? ( - - ) : ( -
- {filtered.map((dep) => { - const risk = describeRiskLevel(dep) - const phase = dep.phase || dep.status - const objective = - dep.orchestration_plan?.objective || - dep.orchestration_plan?.template_hint || - t('No objective') - return ( -
-
-
-
- - - {dep.deployment_id} - + + +
+ + setKeyword(e.target.value)} + placeholder={t('Find deployment / scope / objective')} + className='h-9 w-64 rounded-xl pl-8 text-xs' + /> +
+ + + } + > + {isLoading ? ( + + ) : filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((dep) => { + const risk = describeRiskLevel(dep) + const phase = dep.phase || dep.status + const objective = + dep.orchestration_plan?.objective || + dep.orchestration_plan?.template_hint || + t('No objective') + return ( +
+
+
+
+ + + {dep.deployment_id} + +
+

+ {objective} +

-

- {objective} -

+ +
+ +
+ + +
- -
-
- - - -
- -
- - {formatRelativeTime(dep.updated_at || dep.created_at)} - - -
-
- ) - })} -
- )} -
- +
+ + {formatRelativeTime(dep.updated_at || dep.created_at)} + + +
+ + ) + })} +
+ )} + + ) } @@ -425,13 +466,8 @@ export function AgnetEventsPage() { {deployments.map((dep) => ( - - - {dep.deployment_id} - + + {dep.deployment_id} ))} @@ -528,7 +564,7 @@ export function AgnetEventsPage() { export function AgnetAuditPage() { const { t } = useTranslation() - const [tenant, setTenant] = useState('') + const [scope, setScope] = useState('') const [actor, setActor] = useState('') const [action, setAction] = useState('') @@ -541,28 +577,30 @@ export function AgnetAuditPage() { const filtered = useMemo(() => { return data.filter((entry) => { const e = entry as Record - const t = String(e.tenant || e.tenant_id || '').toLowerCase() + const s = String( + e.binding_scope || e.tenant || e.tenant_id || '' + ).toLowerCase() const a = String(e.actor || e.user || '').toLowerCase() const ac = String(e.action || e.event || '').toLowerCase() - if (tenant && !t.includes(tenant.toLowerCase())) return false + if (scope && !s.includes(scope.toLowerCase())) return false if (actor && !a.includes(actor.toLowerCase())) return false if (action && !ac.includes(action.toLowerCase())) return false return true }) - }, [data, tenant, actor, action]) + }, [data, scope, actor, action]) return ( setTenant(e.target.value)} - placeholder={t('tenant')} + value={scope} + onChange={(e) => setScope(e.target.value)} + placeholder={t('scope')} className='h-9 w-36 rounded-xl text-xs' /> - {t('tenant')} + {t('scope')} @@ -625,7 +663,9 @@ export function AgnetAuditPage() { {String(e.actor || e.user || '—')} - {String(e.tenant || e.tenant_id || '—')} + {String( + e.binding_scope || e.tenant || e.tenant_id || '—' + )} {formatRelativeTime( @@ -837,15 +877,19 @@ export function AgnetSKSourcesPage() { - {t('Project repository')} + + {t('Project repository')} + {t('SK repository')} - {t('Combined repository')} + + {t('Combined repository')} +
-

- {src.name} -

+

{src.name}

{src.repo_url}

@@ -979,75 +1021,75 @@ export function AgnetSKSourcesPage() { {t('Resolved snapshot anchors')}

    - {snapshots.map((entry, idx) => { - const e = entry as Record - const sourceType = String(e.source_type || 'unknown') - const sourceRef = String(e.source_ref || e.ref || '—') - const hash = String(e.hash || e.snapshot_hash || '—') - const resolvedAt = String(e.resolved_at || '') - return ( -
  • -
    -
    -
    - + {snapshots.map((entry, idx) => { + const e = entry as Record + const sourceType = String(e.source_type || 'unknown') + const sourceRef = String(e.source_ref || e.ref || '—') + const hash = String(e.hash || e.snapshot_hash || '—') + const resolvedAt = String(e.resolved_at || '') + return ( +
  • +
    +
    +
    + +
    +
    +

    + {t('source')} +

    +

    + {sourceType} +

    +
    -
    -

    - {t('source')} -

    -

    - {sourceType} -

    + +
    +
    + +
    +
    +

    + {t('reference')} +

    +

    + {sourceRef} +

    +
    +
    + +
    +
    + +
    +
    +

    + {t('hash')} +

    +

    + {hash.length > 18 + ? `${hash.slice(0, 8)}…${hash.slice(-6)}` + : hash} +

    +
    -
    -
    - -
    -
    -

    - {t('reference')} -

    -

    - {sourceRef} -

    -
    -
    - -
    -
    - -
    -
    -

    - {t('hash')} -

    -

    - {hash.length > 18 - ? `${hash.slice(0, 8)}…${hash.slice(-6)}` - : hash} -

    -
    -
    -
    - - {resolvedAt && ( -

    - - {t('resolved')} {formatRelativeTime(resolvedAt)} -

    - )} -
  • - ) - })} + {resolvedAt && ( +

    + + {t('resolved')} {formatRelativeTime(resolvedAt)} +

    + )} + + ) + })}
)} @@ -1076,7 +1118,7 @@ export function AgnetTemplatesPage() { return (
{templates.map((tpl) => ( @@ -1100,8 +1142,8 @@ function runtimeSummary(rt: AgnetRuntimeExecution | undefined): boolean { if (!rt) return false return Boolean( (rt.profile_id && rt.profile_id.trim() !== '') || - (rt.cloud_principal_refs && rt.cloud_principal_refs.length > 0) || - (rt.network_policy_ref && rt.network_policy_ref.trim() !== '') + (rt.cloud_principal_refs && rt.cloud_principal_refs.length > 0) || + (rt.network_policy_ref && rt.network_policy_ref.trim() !== '') ) } @@ -1109,8 +1151,8 @@ function policySummary(p: AgnetSKAccessPolicy | undefined): boolean { if (!p) return false return Boolean( (p.policy_ref && p.policy_ref.trim() !== '') || - (p.deny_skill_ids && p.deny_skill_ids.length > 0) || - p.inherit_deployment_defaults + (p.deny_skill_ids && p.deny_skill_ids.length > 0) || + p.inherit_deployment_defaults ) } @@ -1127,7 +1169,7 @@ export function AgnetAgentsPage() { dep: dep.deployment_id, id: `${dep.deployment_id}-${idx}`, role: agent.role_template || '-', - model: agent.default_model_id || '-', + runtimeModel: agent.default_model_id || '-', goal: agent.goal || '-', runtime: agent.runtime_execution, skPolicy: agent.sk_access_policy, @@ -1139,7 +1181,7 @@ export function AgnetAgentsPage() { return ( {rows.length === 0 ? (

{row.role}

- {row.dep} · model: {row.model} + {row.dep} · {t('Agnet runtime model')}: {row.runtimeModel}

{row.goal}

{runtimeSummary(row.runtime) && ( @@ -1165,8 +1207,7 @@ export function AgnetAgentsPage() { {row.runtime?.profile_id ? (

- {t('Execution profile')} - :{' '} + {t('Execution profile')}:{' '} {row.runtime.profile_id} @@ -1176,8 +1217,7 @@ export function AgnetAgentsPage() { {(row.runtime?.cloud_principal_refs?.length ?? 0) > 0 ? (

- {t('Cloud principals')} - :{' '} + {t('Cloud principals')}:{' '} {row.runtime?.cloud_principal_refs?.join(', ')} @@ -1187,8 +1227,7 @@ export function AgnetAgentsPage() { {row.runtime?.network_policy_ref ? (

- {t('Network policy')} - :{' '} + {t('Network policy')}:{' '} {row.runtime.network_policy_ref} @@ -1205,8 +1244,7 @@ export function AgnetAgentsPage() { {row.skPolicy?.policy_ref ? (

- {t('Policy ref')} - :{' '} + {t('Policy ref')}:{' '} {row.skPolicy.policy_ref} @@ -1216,8 +1254,7 @@ export function AgnetAgentsPage() { {(row.skPolicy?.deny_skill_ids?.length ?? 0) > 0 ? (

- {t('Denied skills')} - :{' '} + {t('Denied skills')}:{' '} {row.skPolicy?.deny_skill_ids?.join(', ')} diff --git a/heicode/web/default/src/features/auth/api.ts b/heicode/web/default/src/features/auth/api.ts index 7d226b6..23781e5 100644 --- a/heicode/web/default/src/features/auth/api.ts +++ b/heicode/web/default/src/features/auth/api.ts @@ -255,12 +255,13 @@ export async function getHeicodeCurrentUser() { if (!me?.success || !me.data) return null + const upstreamId = Number(me.data.id) const roleStr = String(me.data.role || 'user').toLowerCase() const role = roleStr === 'root' ? 100 : roleStr === 'admin' ? 10 : roleStr === 'user' ? 1 : 1 return { - id: 1, + id: Number.isFinite(upstreamId) && upstreamId > 0 ? upstreamId : 1, username: me.data.email || me.data.name || 'heicode-user', display_name: me.data.name || me.data.email || 'Heicode User', email: me.data.email || '', diff --git a/heicode/web/default/src/i18n/locales/en.json b/heicode/web/default/src/i18n/locales/en.json index b046cf9..9e9771b 100644 --- a/heicode/web/default/src/i18n/locales/en.json +++ b/heicode/web/default/src/i18n/locales/en.json @@ -211,12 +211,16 @@ "Inherits deployment defaults": "Inherits deployment defaults", "Add agent": "Add agent", "Agent": "Agent", + "Agent declarations parsed from each Agnet deployment plan.": "Agent declarations parsed from each Agnet deployment plan.", "Agnet deployment created": "Agnet deployment created ({{deployment_id}})", + "Agnet allowed models comma": "Agnet runtime model IDs (comma-separated)", + "Agnet runtime model": "Agnet runtime model", + "Agnet runtime model id": "Agnet runtime model ID", "Allowed models comma": "Allowed model IDs (comma-separated)", - "Budget caps": "Budget caps", + "Budget caps": "Agnet runtime caps", "Cloud principals comma": "Cloud principals (comma-separated)", "Create Agnet deployment": "Create Agnet deployment", - "Create Agnet deployment description": "Send the full orchestration plan in one request. Manager stores it; execution runs on Agnet.", + "Create Agnet deployment description": "Send the orchestration plan to Agnet. Model choices here are runtime policy, not NewAPI billing setup.", "Default model id": "Default model ID", "Deployment plan": "Deployment plan", "Deployment request failed": "Deployment request failed", @@ -566,7 +570,7 @@ "Cancelled": "Cancelled", "Cancelled at": "Cancelled at", "Capture a reusable bundle of models, tags, or endpoints.": "Capture a reusable bundle of models, tags, or endpoints.", - "Card-based view of every Agnet orchestration run with risk, budget, executor and live status.": "Card-based view of every Agnet orchestration run with risk, budget, executor and live status.", + "Card-based view of every Agnet orchestration run with risk, budget, executor and live status.": "Card-based view of every Agnet orchestration run with risk, runtime caps, resource scope and live status.", "Category Name": "Category Name", "Category name is required": "Category name is required", "Category name must be less than 50 characters": "Category name must be less than 50 characters", @@ -1563,7 +1567,8 @@ "Final Consumed": "Final Consumed", "Final cost = base × multiplier when conditions match": "Final cost = base × multiplier when conditions match", "Final price multiplier (0.95 = 5% discount": "Final price multiplier (0.95 = 5% discount", - "Find deployment / tenant / objective": "Find deployment / tenant / objective", + "Find deployment / scope / objective": "Find deployment / scope / objective", + "Find deployment / tenant / objective": "Find deployment / scope / objective", "Fine-tune Midjourney integration and guardrails.": "Fine-tune Midjourney integration and guardrails.", "Finish Time": "Finish Time", "First/Last Frame to Video": "First/Last Frame to Video", @@ -1650,12 +1655,12 @@ "Git sources subtitle": "Configure bindings and deployment parameters for Agnet, then review snapshot anchors per deployment (enforcement lives on Agnet).", "Git sources workflow title": "Typical setup flow", "Git sources workflow step 1": "Bind your team’s Git repositories that hold application code and delivery context.", - "Git sources workflow step 2": "Bind SK tool repositories (skills registry) your tenant is allowed to draw from.", + "Git sources workflow step 2": "Bind SK tool repositories (skills registry) that Resource Grants allow this run to draw from.", "Git sources workflow step 3": "Allocate cloud capacity and permissions for sub-agents—for example dedicated VMs, roles, and API scopes.", "Git sources workflow step 4": "Deploy sub-agents; the deployment wires bound sources and policies into that run.", "Git sources workflow step 5": "After deployment, each sub-agent receives its effective SK allow / deny policy from the control plane.", "Git-backed SK sources": "Git-backed SK sources", - "Git-backed SK sources description": "Bind repos and capacity first; immutable snapshots after each deployment show which SK lineage actually ran under tenant policy.", + "Git-backed SK sources description": "Bind repos and capacity first; immutable snapshots after each deployment show which SK lineage actually ran under Resource Grant policy.", "GitHub": "GitHub", "No Git sources bound yet": "No Git sources bound yet.", "Project repository": "Project repository", @@ -2035,7 +2040,7 @@ "Merchant ID": "Merchant ID", "Merchant ID is required": "Merchant ID is required", "Message Priority": "Message Priority", - "Metadata": "Metadata", + "Metadata": "Deployment scope", "Midjourney": "Midjourney", "MidjourneyPlus": "MidjourneyPlus", "Min Top-up": "Min Top-up", @@ -3011,6 +3016,7 @@ "Scheduled channel tests": "Scheduled channel tests", "Scope": "Scope", "Scopes": "Scopes", + "scope": "scope", "Search": "Search", "Search by name or URL...": "Search by name or URL...", "Search by order number...": "Search by order number...", @@ -3210,7 +3216,8 @@ "Start a conversation to see messages here": "Start a conversation to see messages here", "Start for free with generous limits. No credit card required.": "Start for free with generous limits. No credit card required.", "Start Time": "Start Time", - "Starter orchestration shapes available to tenants.": "Starter orchestration shapes available to tenants.", + "Starter orchestration shapes available to tenants.": "Starter orchestration shapes for resource-scoped runs.", + "Starter orchestration shapes for resource-scoped runs.": "Starter orchestration shapes for resource-scoped runs.", "Static page describing the platform.": "Static page describing the platform.", "Statistical count": "Statistical count", "Statistical quota": "Statistical quota", @@ -3337,6 +3344,10 @@ "Templates": "Templates", "Templates appended": "Templates appended", "tenant": "tenant", + "User scope ref": "User scope ref", + "Resource scope ref": "Resource scope ref", + "Owner scope ref": "Owner scope ref", + "Audit trail for orchestration actions, filtered by user scope, actor, action and time.": "Audit trail for orchestration actions, filtered by user scope, actor, action and time.", "Tenant access": "Tenant access", "Tenant administration": "Tenant administration", "Tenant member": "Tenant member", diff --git a/heicode/web/default/src/i18n/locales/zh.json b/heicode/web/default/src/i18n/locales/zh.json index 4f1f8f1..7ac2af3 100644 --- a/heicode/web/default/src/i18n/locales/zh.json +++ b/heicode/web/default/src/i18n/locales/zh.json @@ -197,7 +197,7 @@ "After enabling, the plan will be shown to users. Continue?": "启用后套餐将在用户端展示。是否继续?", "After invalidating, this subscription will be immediately deactivated. Historical records are not affected. Continue?": "作废后该订阅将立即失效,历史记录不受影响。是否继续?", "After scanning, the binding will complete automatically": "扫描后,绑定将自动完成", - "Agent declarations parsed from each deployment plan.": "从各部署计划中解析的 Agent 声明。", + "Agent declarations parsed from each deployment plan.": "从各 Agnet 部署计划中解析的 Agent 声明。", "Agent ID *": "代理 ID *", "Agentic development control plane": "智能体研发控制面", "Agents": "Agent", @@ -211,12 +211,16 @@ "Inherits deployment defaults": "继承部署默认策略", "Add agent": "添加 Agent", "Agent": "Agent", + "Agent declarations parsed from each Agnet deployment plan.": "从各 Agnet 部署计划中解析的 Agent 声明。", "Agnet deployment created": "Agnet 部署已创建({{deployment_id}})", + "Agnet allowed models comma": "Agnet 运行模型 ID(逗号分隔)", + "Agnet runtime model": "Agnet 运行模型", + "Agnet runtime model id": "Agnet 运行模型 ID", "Allowed models comma": "允许的模型 ID(逗号分隔)", - "Budget caps": "预算上限", + "Budget caps": "Agnet 运行上限", "Cloud principals comma": "云身份引用(逗号分隔)", "Create Agnet deployment": "创建 Agnet 部署", - "Create Agnet deployment description": "在一次请求中提交完整编排计划。Manager 负责记录;实际执行在 Agnet。", + "Create Agnet deployment description": "向 Agnet 提交编排计划。这里的模型选择是运行策略,不是 NewAPI 计费配置。", "Default model id": "默认模型 ID", "Deployment plan": "编排计划", "Deployment request failed": "部署请求失败", @@ -566,7 +570,7 @@ "Cancelled": "已取消", "Cancelled at": "作废于", "Capture a reusable bundle of models, tags, or endpoints.": "捕获可重用的模型、标签或端点捆绑包。", - "Card-based view of every Agnet orchestration run with risk, budget, executor and live status.": "以卡片形式展示每次 Agnet 编排运行,包含风险、预算、执行者与实时状态。", + "Card-based view of every Agnet orchestration run with risk, budget, executor and live status.": "以卡片形式展示每次 Agnet 编排运行,包含风险、运行上限、资源作用域与实时状态。", "Category Name": "分类名称", "Category name is required": "分类名称不能为空", "Category name must be less than 50 characters": "分类名称不能超过 50 个字符", @@ -1563,7 +1567,8 @@ "Final Consumed": "最终消耗", "Final cost = base × multiplier when conditions match": "匹配条件时,最终费用 = 基础费用 × 倍率", "Final price multiplier (0.95 = 5% discount": "最终价格乘数 (0.95 = 5% 折扣", - "Find deployment / tenant / objective": "查找部署 / 租户 / 目标", + "Find deployment / scope / objective": "查找部署 / 作用域 / 目标", + "Find deployment / tenant / objective": "查找部署 / 作用域 / 目标", "Fine-tune Midjourney integration and guardrails.": "微调 Midjourney 集成和防护栏。", "Finish Time": "完成时间", "First/Last Frame to Video": "首尾生视频", @@ -1650,12 +1655,12 @@ "Git sources subtitle": "在部署参数中把绑定与策略交给 Agnet,在此按部署查看快照锚点(执行权在 Agnet)。", "Git sources workflow title": "典型配置顺序", "Git sources workflow step 1": "绑定团队用于应用代码与交付上下文的 Git 仓库。", - "Git sources workflow step 2": "绑定 SK 工具仓库(技能来源),声明租户可引用的工具集。", + "Git sources workflow step 2": "绑定 SK 工具仓库(技能来源),声明本次运行可通过 Resource Grant 引用的工具集。", "Git sources workflow step 3": "为子 Agent 分配云上资源与权限(如独立虚拟机、角色与其他访问边界)。", "Git sources workflow step 4": "部署子 Agent;部署会把已绑定的来源与策略写入该次运行。", "Git sources workflow step 5": "部署完成后,子 Agent 从控制面获知生效的允许 SK 与禁止 SK。", "Git-backed SK sources": "基于 Git 的 SK 来源", - "Git-backed SK sources description": "先完成仓库与云上能力绑定;每次部署后的不可变快照反映在该租户策略下实际运行的 SK 血缘。", + "Git-backed SK sources description": "先完成仓库与云上能力绑定;每次部署后的不可变快照反映在 Resource Grant 策略下实际运行的 SK 血缘。", "GitHub": "GitHub", "No Git sources bound yet": "还没有绑定 Git 来源。", "Project repository": "项目仓库", @@ -2035,7 +2040,7 @@ "Merchant ID": "商户 ID", "Merchant ID is required": "商户 ID 为必填项", "Message Priority": "消息优先级", - "Metadata": "元信息", + "Metadata": "部署作用域", "Midjourney": "Midjourney", "MidjourneyPlus": "MidjourneyPlus", "Min Top-up": "最低充值", @@ -3011,6 +3016,7 @@ "Scheduled channel tests": "定期渠道测试", "Scope": "作用域", "Scopes": "作用域", + "scope": "作用域", "Search": "搜索", "Search by name or URL...": "按名称或 URL 搜索...", "Search by order number...": "按订单号搜索...", @@ -3210,7 +3216,8 @@ "Start a conversation to see messages here": "开始对话以在此处查看消息", "Start for free with generous limits. No credit card required.": "免费开始使用,额度充足,无需绑定信用卡。", "Start Time": "起始时间", - "Starter orchestration shapes available to tenants.": "提供给租户的入门级编排模板。", + "Starter orchestration shapes available to tenants.": "面向资源作用域运行的入门级编排模板。", + "Starter orchestration shapes for resource-scoped runs.": "面向资源作用域运行的入门级编排模板。", "Static page describing the platform.": "描述平台的静态页面。", "Statistical count": "统计计数", "Statistical quota": "统计配额", @@ -3337,6 +3344,10 @@ "Templates": "模板", "Templates appended": "模板已追加", "tenant": "租户", + "User scope ref": "用户作用域引用", + "Resource scope ref": "资源作用域引用", + "Owner scope ref": "归属作用域引用", + "Audit trail for orchestration actions, filtered by user scope, actor, action and time.": "编排操作审计流,可按用户作用域、执行人、操作和时间筛选。", "Tenant access": "租户访问", "Tenant administration": "租户管理", "Tenant member": "租户成员",