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({
-
+
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 (
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- )
- })}
-
- )}
-
-
+
+
+ )
+ })}
+
+ )}
+
+
>
)
}
@@ -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": "租户成员",
|