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
This commit is contained in:
gongzhiyong
2026-05-04 09:28:03 +08:00
parent 5fb432f7c8
commit ba02ae5be7
14 changed files with 934 additions and 455 deletions
@@ -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='<agnet-platform-base-url>'
export MANAGER_SERVICE_TOKEN_SECRET_REF='<secret-ref-only>'
export TENANT_ID='<tenant-id>'
export PROJECT_ID='<project-id>'
export USER_ID='<manager-user-id>'
export BINDING_SCOPE='<resource-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`。 |
@@ -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 或其它受保护服务间通道。
短期凭证注入必须满足:
- 有客户端审批记录。
@@ -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-service-token>` | 是 | Manager 服务身份令牌,由 Secret Store/运行环境注入。 |
| `Content-Type: application/json` | POST/PUT 是 | JSON 请求体。 |
| `X-Tenant-Id: <tenant_id>` | 是 | 租户边界;必须等于 body/query 中 tenant_id。 |
| `X-Project-Id: <project_id>` | 建议 | 项目边界,便于平台鉴权与审计。 |
| `X-User-Id: <user_id>` | 建议 | 登录用户边界;也可从 Manager 服务端 token 或 body `user_context.user_id` 推导。 |
| `X-Binding-Scope: <binding_scope>` | 建议 | Git/SK/云资源作用域;也可从 `resource_grants[].binding_scope` 推导。 |
| `X-Correlation-Id: <uuid>` | 是 | Manager 生成,全链路追踪。 |
| `X-Request-Id: <uuid>` | 建议 | 单次 HTTP 请求追踪 ID,可与 correlation_id 不同。 |
| `Idempotency-Key: <uuid>` | 创建类接口建议 | 避免重试造成重复部署。 |
@@ -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 端点仅作为最小验证与控制面占位,不代表所有日志/监控平台能力已完整实现。
+191 -37
View File
@@ -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,8 +124,8 @@ 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"`
}
@@ -106,6 +135,9 @@ type agnetOrchestrationPlan struct {
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"`
@@ -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",
+87 -13
View File
@@ -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)
+65 -20
View File
@@ -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
}
+23 -29
View File
@@ -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",
+8 -6
View File
@@ -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"`
+62 -15
View File
@@ -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<ApiEnvelope<{ items?: Array<Record<string, unknown>> }>>(
`/api/agnet/deployments/${deploymentId}/events`
)
const res = await api.get<
ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
>(`/api/agnet/deployments/${deploymentId}/events`)
return res.data?.data?.items ?? []
}
export async function getAgnetAuditLogs() {
const res = await api.get<ApiEnvelope<{ items?: Array<Record<string, unknown>> }>>(
'/api/agnet/audit-logs'
)
const res = await api.get<
ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
>('/api/agnet/audit-logs')
return res.data?.data?.items ?? []
}
export async function getAgnetSnapshots(deploymentId: string) {
const res = await api.get<ApiEnvelope<{ items?: Array<Record<string, unknown>> }>>(
`/api/agnet/deployments/${deploymentId}/sk-snapshots`,
{
const res = await api.get<
ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
>(`/api/agnet/deployments/${deploymentId}/sk-snapshots`, {
skipBusinessError: true,
skipErrorHandler: true,
} as Record<string, unknown>
)
} as Record<string, unknown>)
if (!res.data?.success) return []
return res.data?.data?.items ?? []
}
export async function listGitSources(): Promise<GitSource[]> {
const res = await api.get<ApiEnvelope<{ items?: GitSource[] }>>(
'/api/git-sources/'
)
const res =
await api.get<ApiEnvelope<{ items?: GitSource[] }>>('/api/git-sources/')
return res.data?.data?.items ?? []
}
@@ -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<AgentFormRow[]>([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({
</p>
<div className='grid gap-2 sm:grid-cols-2'>
<div>
<Label className='text-xs'>tenant_id</Label>
<Label className='text-xs'>{t('User scope ref')}</Label>
<Input
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
value={userScopeRef}
onChange={(e) => setUserScopeRef(e.target.value)}
className='mt-1 font-mono text-xs'
/>
</div>
<div>
<Label className='text-xs'>project_id</Label>
<Label className='text-xs'>{t('Resource scope ref')}</Label>
<Input
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
value={resourceScopeRef}
onChange={(e) => setResourceScopeRef(e.target.value)}
className='mt-1 font-mono text-xs'
/>
</div>
@@ -380,7 +407,9 @@ export function CreateAgnetDeploymentSheet({
/>
</div>
<div className='grid gap-2'>
<Label className='text-xs'>{t('Allowed models comma')}</Label>
<Label className='text-xs'>
{t('Agnet allowed models comma')}
</Label>
<Input
value={allowedModels}
onChange={(e) => setAllowedModels(e.target.value)}
@@ -460,7 +489,7 @@ export function CreateAgnetDeploymentSheet({
className='text-sm'
/>
<Input
placeholder={t('Default model id')}
placeholder={t('Agnet runtime model id')}
value={agent.default_model_id}
onChange={(e) =>
setAgents((prev) => {
+86 -49
View File
@@ -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 (
<section className='space-y-5 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5 backdrop-blur'>
<header className='flex flex-col gap-3 border-b border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pb-4 sm:flex-row sm:items-end sm:justify-between'>
@@ -120,11 +125,15 @@ function PageSurface(props: { title: string; subtitle?: string; toolbar?: React.
</p>
<h2 className='mt-1 text-xl font-semibold'>{props.title}</h2>
{props.subtitle && (
<p className='mt-1 text-sm text-muted-foreground'>{props.subtitle}</p>
<p className='mt-1 text-sm text-muted-foreground'>
{props.subtitle}
</p>
)}
</div>
{props.toolbar && (
<div className='flex flex-wrap items-center gap-2'>{props.toolbar}</div>
<div className='flex flex-wrap items-center gap-2'>
{props.toolbar}
</div>
)}
</header>
{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 (
<div className='grid gap-3 sm:grid-cols-2'>
{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 (
<span className='inline-flex items-center gap-1.5 rounded-full bg-[color-mix(in_oklch,var(--card)_55%,transparent)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground ring-1 ring-inset ring-border'>
<Icon className='h-3 w-3 text-primary' />
@@ -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
}
@@ -257,7 +295,7 @@ export function AgnetDeploymentsPage() {
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={t('Find deployment / tenant / objective')}
placeholder={t('Find deployment / scope / objective')}
className='h-9 w-64 rounded-xl pl-8 text-xs'
/>
</div>
@@ -325,8 +363,8 @@ export function AgnetDeploymentsPage() {
/>
<MetaPill
icon={Building2}
label={t('tenant')}
value={describeExecutor(dep)}
label={t('scope')}
value={describeScope(dep)}
/>
</div>
@@ -352,7 +390,10 @@ export function AgnetDeploymentsPage() {
</div>
)}
</PageSurface>
<CreateAgnetDeploymentSheet open={createOpen} onOpenChange={setCreateOpen} />
<CreateAgnetDeploymentSheet
open={createOpen}
onOpenChange={setCreateOpen}
/>
</>
)
}
@@ -425,13 +466,8 @@ export function AgnetEventsPage() {
</SelectTrigger>
<SelectContent>
{deployments.map((dep) => (
<SelectItem
key={dep.deployment_id}
value={dep.deployment_id}
>
<span className='font-mono text-xs'>
{dep.deployment_id}
</span>
<SelectItem key={dep.deployment_id} value={dep.deployment_id}>
<span className='font-mono text-xs'>{dep.deployment_id}</span>
</SelectItem>
))}
</SelectContent>
@@ -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<string, unknown>
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 (
<PageSurface
title={t('Audit')}
subtitle={t(
'Cross-tenant-safe audit trail for orchestration actions, filtered by tenant, actor, action and time.'
'Audit trail for orchestration actions, filtered by user scope, actor, action and time.'
)}
toolbar={
<>
<Input
value={tenant}
onChange={(e) => 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'
/>
<Input
@@ -602,7 +640,7 @@ export function AgnetAuditPage() {
</th>
<th className='px-4 py-2 text-left font-semibold'>
<Building2 className='mr-1 inline h-3.5 w-3.5' />
{t('tenant')}
{t('scope')}
</th>
<th className='px-4 py-2 text-left font-semibold'>
<Calendar className='mr-1 inline h-3.5 w-3.5' />
@@ -625,7 +663,9 @@ export function AgnetAuditPage() {
{String(e.actor || e.user || '—')}
</td>
<td className='px-4 py-2 text-xs'>
{String(e.tenant || e.tenant_id || '—')}
{String(
e.binding_scope || e.tenant || e.tenant_id || '—'
)}
</td>
<td className='px-4 py-2 text-xs text-muted-foreground'>
{formatRelativeTime(
@@ -837,15 +877,19 @@ export function AgnetSKSourcesPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='project'>{t('Project repository')}</SelectItem>
<SelectItem value='project'>
{t('Project repository')}
</SelectItem>
<SelectItem value='sk'>{t('SK repository')}</SelectItem>
<SelectItem value='combined'>{t('Combined repository')}</SelectItem>
<SelectItem value='combined'>
{t('Combined repository')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className='text-xs font-medium text-muted-foreground'>
tenant_id
{t('Owner scope ref')}
</label>
<Input
value={gitForm.tenant_id}
@@ -909,9 +953,7 @@ export function AgnetSKSourcesPage() {
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>
{src.name}
</p>
<p className='truncate text-sm font-medium'>{src.name}</p>
<p className='mt-1 truncate font-mono text-[11px] text-muted-foreground'>
{src.repo_url}
</p>
@@ -1076,7 +1118,7 @@ export function AgnetTemplatesPage() {
return (
<PageSurface
title={t('Templates')}
subtitle={t('Starter orchestration shapes available to tenants.')}
subtitle={t('Starter orchestration shapes for resource-scoped runs.')}
>
<div className='grid gap-3 md:grid-cols-2'>
{templates.map((tpl) => (
@@ -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 (
<PageSurface
title={t('Agents')}
subtitle={t('Agent declarations parsed from each deployment plan.')}
subtitle={t('Agent declarations parsed from each Agnet deployment plan.')}
>
{rows.length === 0 ? (
<EmptySurface
@@ -1154,7 +1196,7 @@ export function AgnetAgentsPage() {
>
<p className='text-sm font-medium'>{row.role}</p>
<p className='mt-1 font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground'>
{row.dep} · model: {row.model}
{row.dep} · {t('Agnet runtime model')}: {row.runtimeModel}
</p>
<p className='mt-2 text-sm'>{row.goal}</p>
{runtimeSummary(row.runtime) && (
@@ -1165,8 +1207,7 @@ export function AgnetAgentsPage() {
{row.runtime?.profile_id ? (
<p className='mt-1'>
<span className='text-muted-foreground'>
{t('Execution profile')}
:{' '}
{t('Execution profile')}:{' '}
</span>
<span className='font-mono text-foreground'>
{row.runtime.profile_id}
@@ -1176,8 +1217,7 @@ export function AgnetAgentsPage() {
{(row.runtime?.cloud_principal_refs?.length ?? 0) > 0 ? (
<p className='mt-1'>
<span className='text-muted-foreground'>
{t('Cloud principals')}
:{' '}
{t('Cloud principals')}:{' '}
</span>
<span className='font-mono text-foreground'>
{row.runtime?.cloud_principal_refs?.join(', ')}
@@ -1187,8 +1227,7 @@ export function AgnetAgentsPage() {
{row.runtime?.network_policy_ref ? (
<p className='mt-1'>
<span className='text-muted-foreground'>
{t('Network policy')}
:{' '}
{t('Network policy')}:{' '}
</span>
<span className='font-mono text-foreground'>
{row.runtime.network_policy_ref}
@@ -1205,8 +1244,7 @@ export function AgnetAgentsPage() {
{row.skPolicy?.policy_ref ? (
<p className='mt-1'>
<span className='text-muted-foreground'>
{t('Policy ref')}
:{' '}
{t('Policy ref')}:{' '}
</span>
<span className='font-mono text-foreground'>
{row.skPolicy.policy_ref}
@@ -1216,8 +1254,7 @@ export function AgnetAgentsPage() {
{(row.skPolicy?.deny_skill_ids?.length ?? 0) > 0 ? (
<p className='mt-1'>
<span className='text-muted-foreground'>
{t('Denied skills')}
:{' '}
{t('Denied skills')}:{' '}
</span>
<span className='font-mono text-foreground'>
{row.skPolicy?.deny_skill_ids?.join(', ')}
+2 -1
View File
@@ -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 || '',
+19 -8
View File
@@ -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",
+20 -9
View File
@@ -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": "租户成员",