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 或云访问密钥。 本文用于 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. 执行原则 ## 1. 执行原则
@@ -12,6 +12,7 @@
| Git 发布 | 仅允许快进同步已审核提交;禁止在生产 VM 上提交代码或保存临时补丁。 | | Git 发布 | 仅允许快进同步已审核提交;禁止在生产 VM 上提交代码或保存临时补丁。 |
| 数据库/Redis | Azure PostgreSQL / Redis 连接串只写入 VM 本地 `.env` 或 Secret Store;验证时只打印变量名和连通性结果,不打印值。 | | 数据库/Redis | Azure PostgreSQL / Redis 连接串只写入 VM 本地 `.env` 或 Secret Store;验证时只打印变量名和连通性结果,不打印值。 |
| Agnet / NewAPI | Manager 只传 `secret_ref`、部署计划、资源授权和审计上下文;禁止把明文云账号、数据库密码、模型 Key 放入请求体。 | | Agnet / NewAPI | Manager 只传 `secret_ref`、部署计划、资源授权和审计上下文;禁止把明文云账号、数据库密码、模型 Key 放入请求体。 |
| OpenBao | 只允许 Manager/Agnet 受控网络访问;如果 Manager 提供客户端验证和绑定接口,OpenBao 不暴露公网路由。 |
| 生产动作 | 执行 `up -d`、迁移、重启、回滚前必须记录当前镜像/提交和健康检查 URL;失败时停止扩大变更。 | | 生产动作 | 执行 `up -d`、迁移、重启、回滚前必须记录当前镜像/提交和健康检查 URL;失败时停止扩大变更。 |
## 2. 本地发布前检查 ## 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 服务保留 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 ```bash
@@ -127,13 +135,14 @@ Manager 请求 Agnet 平台时遵循 `docs/integration/agnet-platform-request-co
```bash ```bash
export AGNET_BASE_URL='<agnet-platform-base-url>' export AGNET_BASE_URL='<agnet-platform-base-url>'
export MANAGER_SERVICE_TOKEN_SECRET_REF='<secret-ref-only>' export MANAGER_SERVICE_TOKEN_SECRET_REF='<secret-ref-only>'
export TENANT_ID='<tenant-id>' export USER_ID='<manager-user-id>'
export PROJECT_ID='<project-id>' export BINDING_SCOPE='<resource-binding-scope>'
# 真实 token 由运行环境注入;禁止把 token 字面值写入命令历史或文档。 # 真实 token 由运行环境注入;禁止把 token 字面值写入命令历史或文档。
curl -fsS -X POST "$AGNET_BASE_URL/api/agnet/deployments" \ curl -fsS -X POST "$AGNET_BASE_URL/api/agnet/deployments" \
-H 'Content-Type: application/json' \ -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 "Idempotency-Key: deploy-$(date +%Y%m%d%H%M%S)" \
-H "Authorization: Bearer $MANAGER_SERVICE_TOKEN" \ -H "Authorization: Bearer $MANAGER_SERVICE_TOKEN" \
--data @docs/integration/safe-agnet-deploy-example.json --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`。 | | 服务健康 | `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。 | | 容器状态 | `docker compose ... ps heicode` | `heicode` 为 running/healthy。 |
| Git 版本 | `git rev-parse --short HEAD` | 与已审核提交一致。 | | Git 版本 | `git rev-parse --short HEAD` | 与已审核提交一致。 |
| DB/Redis 注入 | `awk -F= ... .env` | 只打印 key,包含 `SQL_DSN`、`REDIS_CONN_STRING`。 | | DB/Redis 注入 | `awk -F= ... .env` | 只打印 key,包含 `SQL_DSN`、`REDIS_CONN_STRING`。 |
@@ -71,6 +71,7 @@ Manager 可以有本地 user cache,但 canonical user identity 应来自登录
-> 用户授权或绑定资源 -> 用户授权或绑定资源
-> Manager 写入 OpenBao -> Manager 写入 OpenBao
-> Manager DB 只保存 secret_ref -> 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 状态。 允许注入子 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 平台接口参数文档 # Manager → Agnet 平台接口参数文档
**版本**: v0.2(P1/P5 联调契约) **版本**: v0.3(P1/P5 联调契约)
**生效日期**: 2026-05-03 **生效日期**: 2026-05-03
**状态**: 联调准备;当前仓库提供 Manager 侧最小验证端点,生产 Agnet 平台部署尚未在本文档中宣称完成。 **状态**: 联调准备;当前仓库提供 Manager 侧最小验证端点,生产 Agnet 平台部署尚未在本文档中宣称完成。
**方向**: Heicode Manager 主动请求 Agnet 平台;Agnet 平台返回部署、日志、监控与审计状态。 **方向**: Heicode Manager 主动请求 Agnet 平台;Agnet 平台返回部署、日志、监控与审计状态。
**范围**: 创建/停止子 Agent 部署、查询部署、获取事件/日志/监控快照、解析 SK 快照、查询审计日志,以及 Agnet 辅助 NewAPI 重建/部署的参数约定。 **范围**: 创建/停止子 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、私钥、连接串或云访问密钥。 **安全红线**: 请求体只允许传资源元数据、权限范围与 `secret_ref`/环境变量名;不得传明文密码、Token、私钥、连接串或云访问密钥。
> 本文档描述 Manager 对 Agnet 平台的出站集成契约。当前仓库中 `/api/agnet/*` 是 Manager 侧最小控制面/模拟端点,用于校验同一套 payload 结构;生产接入时,Manager 应将下列请求发送到 Agnet 平台网关。 > 本文档描述 Manager 对 Agnet 平台的出站集成契约。当前仓库中 `/api/agnet/*` 是 Manager 侧最小控制面/模拟端点,用于校验同一套 payload 结构;生产接入时,Manager 应将下列请求发送到 Agnet 平台网关。
@@ -21,7 +23,7 @@
| `POST /api/agnet/deployments/{deployment_id}/stop` | 停止部署或取消排队任务 | 必需 | | `POST /api/agnet/deployments/{deployment_id}/stop` | 停止部署或取消排队任务 | 必需 |
| `GET /api/agnet/deployments/{deployment_id}/logs` | 拉取部署日志 | 必需 | | `GET /api/agnet/deployments/{deployment_id}/logs` | 拉取部署日志 | 必需 |
| `GET /api/agnet/deployments/{deployment_id}/logs/stream` | 实时日志 SSE | 可选 | | `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}/metrics` | 单部署指标序列 | 建议 |
| `GET /api/agnet/deployments/{deployment_id}/events` | 部署事件 | 必需 | | `GET /api/agnet/deployments/{deployment_id}/events` | 部署事件 | 必需 |
| `GET /api/agnet/audit-logs` | 审计日志 | 必需 | | `GET /api/agnet/audit-logs` | 审计日志 | 必需 |
@@ -46,7 +48,7 @@ AGNET_PLATFORM_BASE_URL=https://agnet-platform.example.com
```text ```text
AGNET_PLATFORM_BASE_URL=https://staging-agnet.example.com 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/运行环境注入。 | | `Authorization: Bearer <manager-service-token>` | 是 | Manager 服务身份令牌,由 Secret Store/运行环境注入。 |
| `Content-Type: application/json` | POST/PUT 是 | JSON 请求体。 | | `Content-Type: application/json` | POST/PUT 是 | JSON 请求体。 |
| `X-Tenant-Id: <tenant_id>` | 是 | 租户边界;必须等于 body/query 中 tenant_id。 | | `X-User-Id: <user_id>` | 建议 | 登录用户边界;也可从 Manager 服务端 token 或 body `user_context.user_id` 推导。 |
| `X-Project-Id: <project_id>` | 建议 | 项目边界,便于平台鉴权与审计。 | | `X-Binding-Scope: <binding_scope>` | 建议 | Git/SK/云资源作用域;也可从 `resource_grants[].binding_scope` 推导。 |
| `X-Correlation-Id: <uuid>` | 是 | Manager 生成,全链路追踪。 | | `X-Correlation-Id: <uuid>` | 是 | Manager 生成,全链路追踪。 |
| `X-Request-Id: <uuid>` | 建议 | 单次 HTTP 请求追踪 ID,可与 correlation_id 不同。 | | `X-Request-Id: <uuid>` | 建议 | 单次 HTTP 请求追踪 ID,可与 correlation_id 不同。 |
| `Idempotency-Key: <uuid>` | 创建类接口建议 | 避免重试造成重复部署。 | | `Idempotency-Key: <uuid>` | 创建类接口建议 | 避免重试造成重复部署。 |
@@ -99,8 +101,8 @@ POST https://agnet-platform.example.com/api/agnet/deployments
| `POLICY_REJECTED` | 缺必填字段、权限策略不满足、风险等级非法。 | | `POLICY_REJECTED` | 缺必填字段、权限策略不满足、风险等级非法。 |
| `BUDGET_EXCEEDED` | 超过 token/金额/时长预算。 | | `BUDGET_EXCEEDED` | 超过 token/金额/时长预算。 |
| `MODEL_NOT_ALLOWED` | agent 默认模型不在允许列表内。 | | `MODEL_NOT_ALLOWED` | agent 默认模型不在允许列表内。 |
| `FORBIDDEN_CROSS_TENANT` | Header 与 body/query 租户不一致。 | | `FORBIDDEN_SCOPE` | Header 与 body/query 的用户或资源作用域不一致。 |
| `RESOURCE_GRANT_INVALID` | Resource Grant 字段缺失、跨租户/跨项目/角色不匹配。 | | `RESOURCE_GRANT_INVALID` | Resource Grant 字段缺失、跨用户/跨资源作用域/角色不匹配。 |
| `RESOURCE_GRANT_SECRET_REF_REQUIRED` | 凭据型资源缺少 `secret_ref`。 | | `RESOURCE_GRANT_SECRET_REF_REQUIRED` | 凭据型资源缺少 `secret_ref`。 |
| `RESOURCE_GRANT_SECRET_REJECTED` | 请求中出现明文密钥字段。 | | `RESOURCE_GRANT_SECRET_REJECTED` | 请求中出现明文密钥字段。 |
| `SK_SOURCE_UNRESOLVABLE` | SK 来源不可解析。 | | `SK_SOURCE_UNRESOLVABLE` | SK 来源不可解析。 |
@@ -116,7 +118,7 @@ POST https://agnet-platform.example.com/api/agnet/deployments
|---:|---|---| |---:|---|---|
| 400 | `POLICY_REJECTED` / `RESOURCE_GRANT_INVALID` | 标记部署失败,展示校验原因,不自动重试。 | | 400 | `POLICY_REJECTED` / `RESOURCE_GRANT_INVALID` | 标记部署失败,展示校验原因,不自动重试。 |
| 401 | `UNAUTHORIZED` | 检查 Manager 服务令牌的 `secret_ref`/环境注入,不把令牌写入日志。 | | 401 | `UNAUTHORIZED` | 检查 Manager 服务令牌的 `secret_ref`/环境注入,不把令牌写入日志。 |
| 403 | `FORBIDDEN_CROSS_TENANT` / `MODEL_NOT_ALLOWED` | 阻断本次部署,写审计事件。 | | 403 | `FORBIDDEN_SCOPE` / `MODEL_NOT_ALLOWED` | 阻断本次部署,写审计事件。 |
| 404 | `NOT_FOUND` | 对查询类接口返回空态;对控制类接口提示资源不存在。 | | 404 | `NOT_FOUND` | 对查询类接口返回空态;对控制类接口提示资源不存在。 |
| 409 | `DEPLOYMENT_CONFLICT` | 使用 `Idempotency-Key` 查询既有结果,避免重复创建。 | | 409 | `DEPLOYMENT_CONFLICT` | 使用 `Idempotency-Key` 查询既有结果,避免重复创建。 |
| 410 | `CURSOR_EXPIRED` | 丢弃 cursor,使用 `since` 重新拉取。 | | 410 | `CURSOR_EXPIRED` | 丢弃 cursor,使用 `since` 重新拉取。 |
@@ -141,19 +143,42 @@ POST /api/agnet/deployments
"orchestration_plan": { "orchestration_plan": {
"intent_id": "intent_20260502_001", "intent_id": "intent_20260502_001",
"template_hint": "manager-resource-binding", "template_hint": "manager-resource-binding",
"objective": "为项目 project-a 启动 builder 子 Agent,允许其读取 SK 并在限定路径内提交代码", "objective": "为已绑定的代码仓库启动 builder 子 Agent,允许其读取 SK 并在限定路径内提交代码",
"risk_level": "low", "risk_level": "low",
"budget": { "budget": {
"max_tokens": 100000, "max_tokens": 100000,
"max_cost_usd": 30, "max_cost_usd": 30,
"max_duration_sec": 7200 "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": { "constraints": {
"allowed_model_ids": ["gpt-5.4-mini", "gpt-5.4"] "allowed_model_ids": ["gpt-5.4-mini", "gpt-5.4"]
}, },
"metadata": { "metadata": {
"tenant_id": "tenant-a", "tenant_id": "legacy-user-scope",
"project_id": "project-a", "project_id": "legacy-resource-scope",
"correlation_id": "corr_20260502_001" "correlation_id": "corr_20260502_001"
}, },
"agents": [ "agents": [
@@ -175,11 +200,11 @@ POST /api/agnet/deployments
], ],
"runtime_execution": { "runtime_execution": {
"profile_id": "aks-codex-standard", "profile_id": "aks-codex-standard",
"cloud_principal_refs": ["principal://tenant-a/agnet-runtime"], "cloud_principal_refs": ["principal://users/user_123/agnet-runtime"],
"network_policy_ref": "netpol://tenant-a/restricted-egress" "network_policy_ref": "netpol://bindings/repo_default/restricted-egress"
}, },
"sk_access_policy": { "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"], "deny_skill_ids": ["dangerous-shell"],
"inherit_deployment_defaults": true "inherit_deployment_defaults": true
}, },
@@ -188,8 +213,10 @@ POST /api/agnet/deployments
"grant_id": "grant_git_repo_001", "grant_id": "grant_git_repo_001",
"resource_id": "res_git_repo_001", "resource_id": "res_git_repo_001",
"resource_type": "git", "resource_type": "git",
"tenant_id": "tenant-a", "user_id": "user_123",
"project_id": "project-a", "binding_scope": "repo_default",
"tenant_id": "legacy-user-scope",
"project_id": "legacy-resource-scope",
"target_role": "builder", "target_role": "builder",
"target_agent_ref": "agent-builder-1", "target_agent_ref": "agent-builder-1",
"permission_scope": ["repo:read", "repo:write:current-branch"], "permission_scope": ["repo:read", "repo:write:current-branch"],
@@ -203,7 +230,7 @@ POST /api/agnet/deployments
"repo_url": "https://example.com/org/repo.git" "repo_url": "https://example.com/org/repo.git"
}, },
"status": "active", "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": { "audit": {
"created_by": "manager", "created_by": "manager",
"approval_id": "approval_001" "approval_id": "approval_001"
@@ -213,8 +240,10 @@ POST /api/agnet/deployments
"grant_id": "grant_doc_001", "grant_id": "grant_doc_001",
"resource_id": "res_project_doc_001", "resource_id": "res_project_doc_001",
"resource_type": "project_doc", "resource_type": "project_doc",
"tenant_id": "tenant-a", "user_id": "user_123",
"project_id": "project-a", "binding_scope": "repo_default",
"tenant_id": "legacy-user-scope",
"project_id": "legacy-resource-scope",
"target_role": "builder", "target_role": "builder",
"target_agent_ref": "agent-builder-1", "target_agent_ref": "agent-builder-1",
"permission_scope": ["doc:read"], "permission_scope": ["doc:read"],
@@ -249,12 +278,30 @@ POST /api/agnet/deployments
| `budget.max_tokens` | int | 是 | 当前策略上限建议不超过 `500000`。 | | `budget.max_tokens` | int | 是 | 当前策略上限建议不超过 `500000`。 |
| `budget.max_cost_usd` | number | 是 | 当前策略上限建议不超过 `200`。 | | `budget.max_cost_usd` | number | 是 | 当前策略上限建议不超过 `200`。 |
| `budget.max_duration_sec` | int | 是 | 当前策略上限建议不超过 `86400`。 | | `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` 如填写,必须在此列表内。 | | `constraints.allowed_model_ids` | string[] | 否 | agent 的 `default_model_id` 如填写,必须在此列表内。 |
| `metadata.tenant_id` | string | 是 | 必须与 `X-Tenant-Id` 一致。 | | `metadata.tenant_id` | string | 否 | 旧兼容字段;新实现不得作为产品租户边界。 |
| `metadata.project_id` | string | 是 | 项目隔离边界。 | | `metadata.project_id` | string | 否 | 旧兼容字段;新实现不得作为项目账本边界。 |
| `metadata.correlation_id` | string | 是 | 全链路追踪 ID。 | | `metadata.correlation_id` | string | 是 | 全链路追踪 ID。 |
| `agents` | array | 是 | 至少 1 个子 Agent。 | | `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[] #### agents[]
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
@@ -294,8 +341,10 @@ POST /api/agnet/deployments
| `grant_id` | string | 是 | 授权记录 ID。 | | `grant_id` | string | 是 | 授权记录 ID。 |
| `resource_id` | string | 是 | Manager 资源 ID。 | | `resource_id` | string | 是 | Manager 资源 ID。 |
| `resource_type` | enum | 是 | `git` / `sk` / `project_doc` / `cloud_account` / `cloud_resource`。 | | `resource_type` | enum | 是 | `git` / `sk` / `project_doc` / `cloud_account` / `cloud_resource`。 |
| `tenant_id` | string | 是 | 必须等于 orchestration metadata。 | | `user_id` | string | 建议 | 与 `user_context.user_id` 一致;为空时平台可从部署上下文推导。 |
| `project_id` | string | 是 | 必须等于 orchestration metadata。 | | `binding_scope` | string | 是 | Git/SK/云资源作用域,例如 repo/ref/path 或云资源引用。 |
| `tenant_id` | string | 否 | 旧兼容字段;新实现不应依赖。 |
| `project_id` | string | 否 | 旧兼容字段;新实现不应依赖。 |
| `target_role` | string | 是 | 必须等于当前 agent 的 `role_template`。 | | `target_role` | string | 是 | 必须等于当前 agent 的 `role_template`。 |
| `target_agent_ref` | string | 是 | Manager 侧对子 Agent 的逻辑引用。 | | `target_agent_ref` | string | 是 | Manager 侧对子 Agent 的逻辑引用。 |
| `permission_scope` | string[] | 是 | 最小权限列表,如 `repo:read`、`doc:read`。 | | `permission_scope` | string[] | 是 | 最小权限列表,如 `repo:read`、`doc:read`。 |
@@ -330,8 +379,8 @@ POST /api/agnet/deployments
"healthcheck_url_ref": "env://NEWAPI_HEALTHCHECK_URL" "healthcheck_url_ref": "env://NEWAPI_HEALTHCHECK_URL"
}, },
"metadata": { "metadata": {
"tenant_id": "tenant-a", "tenant_id": "legacy-user-scope",
"project_id": "newapi-prod", "project_id": "legacy-newapi-scope",
"correlation_id": "corr_newapi_20260503_001", "correlation_id": "corr_newapi_20260503_001",
"service": "new-api", "service": "new-api",
"environment": "production", "environment": "production",
@@ -345,16 +394,18 @@ POST /api/agnet/deployments
"default_model_id": "gpt-5.4", "default_model_id": "gpt-5.4",
"runtime_execution": { "runtime_execution": {
"profile_id": "aks-codex-ops", "profile_id": "aks-codex-ops",
"cloud_principal_refs": ["principal://tenant-a/agnet-ops"], "cloud_principal_refs": ["principal://users/user_123/agnet-ops"],
"network_policy_ref": "netpol://tenant-a/ops-egress" "network_policy_ref": "netpol://bindings/newapi-prod/ops-egress"
}, },
"resource_grants": [ "resource_grants": [
{ {
"grant_id": "grant_newapi_vm_ops", "grant_id": "grant_newapi_vm_ops",
"resource_id": "res_newapi_vm", "resource_id": "res_newapi_vm",
"resource_type": "cloud_resource", "resource_type": "cloud_resource",
"tenant_id": "tenant-a", "user_id": "user_123",
"project_id": "newapi-prod", "binding_scope": "newapi-prod",
"tenant_id": "legacy-user-scope",
"project_id": "legacy-newapi-scope",
"target_role": "operator", "target_role": "operator",
"target_agent_ref": "agent-operator-1", "target_agent_ref": "agent-operator-1",
"permission_scope": ["vm:ssh:approved-window", "service:restart", "log:read", "healthcheck:read"], "permission_scope": ["vm:ssh:approved-window", "service:restart", "log:read", "healthcheck:read"],
@@ -368,7 +419,7 @@ POST /api/agnet/deployments
"service_name": "new-api" "service_name": "new-api"
}, },
"status": "active", "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": { "audit": {
"created_by": "manager", "created_by": "manager",
"approval_id": "approval_newapi_001" "approval_id": "approval_newapi_001"
@@ -378,8 +429,10 @@ POST /api/agnet/deployments
"grant_id": "grant_newapi_runtime_env", "grant_id": "grant_newapi_runtime_env",
"resource_id": "res_newapi_runtime_env", "resource_id": "res_newapi_runtime_env",
"resource_type": "cloud_resource", "resource_type": "cloud_resource",
"tenant_id": "tenant-a", "user_id": "user_123",
"project_id": "newapi-prod", "binding_scope": "newapi-prod",
"tenant_id": "legacy-user-scope",
"project_id": "legacy-newapi-scope",
"target_role": "operator", "target_role": "operator",
"target_agent_ref": "agent-operator-1", "target_agent_ref": "agent-operator-1",
"permission_scope": ["env:read:runtime", "secret:read:scoped"], "permission_scope": ["env:read:runtime", "secret:read:scoped"],
@@ -388,11 +441,11 @@ POST /api/agnet/deployments
"plaintext_export_forbidden": "true" "plaintext_export_forbidden": "true"
}, },
"metadata": { "metadata": {
"secret_provider": "vault", "secret_provider": "openbao",
"scope": "newapi-runtime" "scope": "newapi-runtime"
}, },
"status": "active", "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": { "audit": {
"created_by": "manager", "created_by": "manager",
"approval_id": "approval_newapi_001" "approval_id": "approval_newapi_001"
@@ -441,7 +494,7 @@ NewAPI 重建/部署的完成判定必须同时满足:
### 3.1 查询部署列表 ### 3.1 查询部署列表
```http ```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 处理建议 | | HTTP | business code | Manager 处理建议 |
|---:|---|---| |---:|---|---|
| 401/403 | `UNAUTHORIZED` / `FORBIDDEN_CROSS_TENANT` | 立即断开流并记录审计。 | | 401/403 | `UNAUTHORIZED` / `FORBIDDEN_SCOPE` | 立即断开流并记录审计。 |
| 404 | `NOT_FOUND` | 停止订阅并刷新部署详情。 | | 404 | `NOT_FOUND` | 停止订阅并刷新部署详情。 |
| 429 | `RATE_LIMITED` | 退避后重连,保留 `Last-Event-Id`。 | | 429 | `RATE_LIMITED` | 退避后重连,保留 `Last-Event-Id`。 |
@@ -598,7 +651,7 @@ SSE 事件类型:
### 5.1 项目监控快照 ### 5.1 项目监控快照
```http ```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_id": "evt_001",
"event": "deployment.accepted", "event": "deployment.accepted",
"schema_version": 1, "schema_version": 1,
"tenant_id": "tenant-a", "user_id": "user_123",
"project_id": "project-a", "channel_id": "channel_abc",
"binding_scope": "repo_default",
"deployment_id": "dep_abc123", "deployment_id": "dep_abc123",
"correlation_id": "corr_20260502_001", "correlation_id": "corr_20260502_001",
"occurred_at": "2026-05-02T00:00:00Z" "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 审计日志 ### 6.2 审计日志
```http ```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", "actor": "agnet_control_plane",
"action": "deployment.accepted", "action": "deployment.accepted",
"resource": "dep_abc123", "resource": "dep_abc123",
"tenant_id": "tenant-a", "user_id": "user_123",
"project_id": "project-a", "channel_id": "channel_abc",
"binding_scope": "repo_default",
"request_id": "req_001", "request_id": "req_001",
"correlation_id": "corr_20260502_001", "correlation_id": "corr_20260502_001",
"result": "ok", "result": "ok",
@@ -772,8 +827,8 @@ POST /api/agnet/sk-snapshots/resolve
{ {
"snapshot_id": "sks_001", "snapshot_id": "sks_001",
"deployment_id": "dep_abc123", "deployment_id": "dep_abc123",
"tenant_id": "tenant-a", "user_id": "user_123",
"project_id": "project-a", "binding_scope": "repo_default",
"source_type": "git", "source_type": "git",
"source_ref": "main:skills/heicode/**@sha_xxx", "source_ref": "main:skills/heicode/**@sha_xxx",
"resolved_at": "2026-05-02T00:00:00Z" "resolved_at": "2026-05-02T00:00:00Z"
@@ -794,8 +849,8 @@ Query:
| 参数 | 必填 | 说明 | | 参数 | 必填 | 说明 |
|---|---:|---| |---|---:|---|
| `tenant_id` | 建议 | 与 `X-Tenant-Id` 一致;平台可从 Header 推导。 | | `user_id` | 建议 | 与 `X-User-Id` 或 deployment 的 `user_context.user_id` 一致。 |
| `project_id` | 建议 | 项目边界;平台可从 deployment 推导。 | | `binding_scope` | 建议 | 与 `X-Binding-Scope` 或 deployment 的资源授权作用域一致。 |
| `source_type` | 否 | `git` / `upload`,用于筛选。 | | `source_type` | 否 | `git` / `upload`,用于筛选。 |
| `limit` | 否 | 默认 100,最大 500。 | | `limit` | 否 | 默认 100,最大 500。 |
| `cursor` | 否 | 分页游标。 | | `cursor` | 否 | 分页游标。 |
@@ -810,11 +865,11 @@ Query:
{ {
"snapshot_id": "sks_001", "snapshot_id": "sks_001",
"deployment_id": "dep_abc123", "deployment_id": "dep_abc123",
"tenant_id": "tenant-a", "user_id": "user_123",
"project_id": "project-a", "binding_scope": "repo_default",
"source_type": "git", "source_type": "git",
"source_ref": "main:skills/heicode/**@sha_xxx", "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", "checksum": "sha256:example-redacted",
"status": "ready", "status": "ready",
"resolved_at": "2026-05-02T00:00:00Z" "resolved_at": "2026-05-02T00:00:00Z"
@@ -840,7 +895,7 @@ Query:
Manager 发给 Agnet 平台前必须执行: Manager 发给 Agnet 平台前必须执行:
1. `tenant_id`、`project_id`、`target_role` 与部署计划一致。 1. `user_id`、`binding_scope`、`target_role` 与部署计划一致。
2. 凭据型资源只传 `secret_ref`,不传明文凭据。 2. 凭据型资源只传 `secret_ref`,不传明文凭据。
3. `metadata`、`constraints`、`audit` 的 key 中不得出现 `password`、`token`、`secret`、`private_key`、`access_key`、`credential` 等敏感词。 3. `metadata`、`constraints`、`audit` 的 key 中不得出现 `password`、`token`、`secret`、`private_key`、`access_key`、`credential` 等敏感词。
4. `repo_url` 不得包含用户名、密码或访问 Token。 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 审计的指令。 | | `agents[]` | `role_template`、`goal` | 让子 Agent 绕过 Manager/Agnet 审计的指令。 |
| `runtime_execution` | 任一字段存在时 `profile_id` 必填 | 明文 kubeconfig、SSH key、云访问密钥。 | | `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、密钥片段。 | | 日志/事件/审计返回 | `request_id` 或 `correlation_id`,以及发生时间 | 未脱敏命令行、环境变量 dump、密钥片段。 |
| NewAPI 重建/部署场景 | `approval_id`、`rollback_command_ref`、健康检查引用、运行环境 `secret_ref` | 真实 VM 密码、PostgreSQL/Redis 连接串、NewAPI 服务令牌。 | | NewAPI 重建/部署场景 | `approval_id`、`rollback_command_ref`、健康检查引用、运行环境 `secret_ref` | 真实 VM 密码、PostgreSQL/Redis 连接串、NewAPI 服务令牌。 |
### 8.2 联调验收清单 ### 8.2 联调验收清单
- 创建部署请求只包含 `secret_ref`/`env://`/`runbook://` 引用,不包含真实凭据。 - 创建部署请求只包含 `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` 和回滚引用。 - `risk_level=high` 的生产运维任务包含 `approval_id` 和回滚引用。
- 日志、事件、监控、审计接口都能通过 `correlation_id` 串联。 - 日志、事件、监控、审计接口都能通过 `correlation_id` 串联。
- NewAPI 重建/部署只在实际执行并通过健康检查后标记为已部署;未执行时状态只能是 `planned`、`pending_approval`、`accepted` 或 `running`。 - NewAPI 重建/部署只在实际执行并通过健康检查后标记为已部署;未执行时状态只能是 `planned`、`pending_approval`、`accepted` 或 `running`。
@@ -877,7 +932,7 @@ Manager 发给 Agnet 平台前必须执行:
| Manager 路由 | 用途 | | Manager 路由 | 用途 |
|---|---| |---|---|
| `POST /api/agnet/deployments` | 校验并接受 orchestration_plan。 | | `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` | 查询部署详情。 | | `GET /api/agnet/deployments/:deployment_id` | 查询部署详情。 |
| `POST /api/agnet/deployments/:deployment_id/stop` | 停止部署。 | | `POST /api/agnet/deployments/:deployment_id/stop` | 停止部署。 |
| `GET /api/agnet/deployments/:deployment_id/logs` | 查询脱敏日志占位/联调日志。 | | `GET /api/agnet/deployments/:deployment_id/logs` | 查询脱敏日志占位/联调日志。 |
@@ -885,7 +940,7 @@ Manager 发给 Agnet 平台前必须执行:
| `GET /api/agnet/deployments/:deployment_id/events` | 查询事件。 | | `GET /api/agnet/deployments/:deployment_id/events` | 查询事件。 |
| `POST /api/agnet/sk-snapshots/resolve` | 解析 SK 快照。 | | `POST /api/agnet/sk-snapshots/resolve` | 解析 SK 快照。 |
| `GET /api/agnet/deployments/:deployment_id/sk-snapshots` | 查询 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` | 审计日志。 | | `GET /api/agnet/audit-logs` | 审计日志。 |
生产对接时,Manager 应把相同契约的请求发送给 Agnet 平台;本地 Manager 端点仅作为最小验证与控制面占位,不代表所有日志/监控平台能力已完整实现。 生产对接时,Manager 应把相同契约的请求发送给 Agnet 平台;本地 Manager 端点仅作为最小验证与控制面占位,不代表所有日志/监控平台能力已完整实现。
+191 -37
View File
@@ -1,6 +1,7 @@
package controller package controller
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@@ -33,6 +34,32 @@ type agnetBudget struct {
MaxDurationSec int `json:"max_duration_sec"` 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). // agnetRepoRef matches docs/integration/orchestration-plan-contract.md (git sk_sources).
type agnetRepoRef struct { type agnetRepoRef struct {
ConnectionID string `json:"connection_id"` ConnectionID string `json:"connection_id"`
@@ -68,8 +95,10 @@ type agnetResourceGrant struct {
GrantID string `json:"grant_id"` GrantID string `json:"grant_id"`
ResourceID string `json:"resource_id"` ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"` ResourceType string `json:"resource_type"`
TenantID string `json:"tenant_id"` UserID string `json:"user_id"`
ProjectID string `json:"project_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"` TargetRole string `json:"target_role"`
TargetAgentRef string `json:"target_agent_ref"` TargetAgentRef string `json:"target_agent_ref"`
PermissionScope []string `json:"permission_scope"` PermissionScope []string `json:"permission_scope"`
@@ -95,8 +124,8 @@ type agnetConstraints struct {
} }
type agnetMetadata struct { type agnetMetadata struct {
TenantID string `json:"tenant_id"` TenantID string `json:"tenant_id,omitempty"` // legacy compatibility only.
ProjectID string `json:"project_id"` ProjectID string `json:"project_id,omitempty"` // legacy compatibility only.
CorrelationID string `json:"correlation_id"` CorrelationID string `json:"correlation_id"`
} }
@@ -106,6 +135,9 @@ type agnetOrchestrationPlan struct {
Objective string `json:"objective"` Objective string `json:"objective"`
RiskLevel string `json:"risk_level"` RiskLevel string `json:"risk_level"`
Budget agnetBudget `json:"budget"` Budget agnetBudget `json:"budget"`
UserContext agnetUserContext `json:"user_context"`
BillingContext agnetBillingContext `json:"billing_context"`
AgentRuntime agnetAgentRuntime `json:"agent_runtime"`
Agents []agnetAgentPlan `json:"agents"` Agents []agnetAgentPlan `json:"agents"`
Constraints agnetConstraints `json:"constraints"` Constraints agnetConstraints `json:"constraints"`
Metadata agnetMetadata `json:"metadata"` Metadata agnetMetadata `json:"metadata"`
@@ -128,8 +160,9 @@ type agnetEvent struct {
EventID string `json:"event_id"` EventID string `json:"event_id"`
Event string `json:"event"` Event string `json:"event"`
SchemaVersion int `json:"schema_version"` SchemaVersion int `json:"schema_version"`
TenantID string `json:"tenant_id"` UserID string `json:"user_id"`
ProjectID string `json:"project_id"` ChannelID string `json:"channel_id"`
BindingScope string `json:"binding_scope"`
DeploymentID string `json:"deployment_id"` DeploymentID string `json:"deployment_id"`
CorrelationID string `json:"correlation_id"` CorrelationID string `json:"correlation_id"`
OccurredAt string `json:"occurred_at"` OccurredAt string `json:"occurred_at"`
@@ -142,8 +175,8 @@ type agnetSKSnapshotResolveRequest struct {
type agnetSKSnapshot struct { type agnetSKSnapshot struct {
SnapshotID string `json:"snapshot_id"` SnapshotID string `json:"snapshot_id"`
DeploymentID string `json:"deployment_id"` DeploymentID string `json:"deployment_id"`
TenantID string `json:"tenant_id"` UserID string `json:"user_id"`
ProjectID string `json:"project_id"` BindingScope string `json:"binding_scope"`
SourceType string `json:"source_type"` SourceType string `json:"source_type"`
SourceRef string `json:"source_ref"` SourceRef string `json:"source_ref"`
ResolvedAt string `json:"resolved_at"` ResolvedAt string `json:"resolved_at"`
@@ -295,6 +328,62 @@ func validateAgentSKAccessPolicy(c *gin.Context, agent agnetAgentPlan) bool {
return true 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 { func validateResourceGrant(c *gin.Context, plan agnetOrchestrationPlan, agent agnetAgentPlan, grant agnetResourceGrant) bool {
resourceType := strings.TrimSpace(grant.ResourceType) resourceType := strings.TrimSpace(grant.ResourceType)
switch 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") agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants require grant_id/resource_id/target_role/target_agent_ref")
return false 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 return false
} }
if strings.TrimSpace(grant.TargetRole) != strings.TrimSpace(agent.RoleTemplate) { 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") agnetError(c, "POLICY_REJECTED", "at least one agent is required")
return false return false
} }
if strings.TrimSpace(plan.Metadata.TenantID) == "" || if strings.TrimSpace(plan.UserContext.UserID) == "" {
strings.TrimSpace(plan.Metadata.ProjectID) == "" || agnetError(c, "POLICY_REJECTED", "user_context.user_id is required")
strings.TrimSpace(plan.Metadata.CorrelationID) == "" { return false
agnetError(c, "POLICY_REJECTED", "metadata.tenant_id/project_id/correlation_id is required") }
if strings.TrimSpace(plan.Metadata.CorrelationID) == "" {
agnetError(c, "POLICY_REJECTED", "metadata.correlation_id is required")
return false return false
} }
switch plan.RiskLevel { 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") agnetError(c, "BUDGET_EXCEEDED", "budget exceeds current platform policy limits")
return false return false
} }
if !validateBillingContext(c, plan) {
if tenantHeader := strings.TrimSpace(c.GetHeader("X-Tenant-Id")); tenantHeader != "" && tenantHeader != plan.Metadata.TenantID { return false
agnetError(c, "FORBIDDEN_CROSS_TENANT", "X-Tenant-Id does not match orchestration_plan metadata.tenant_id") }
if !validateAgentRuntimeContext(c, plan) {
return false return false
} }
@@ -407,6 +507,50 @@ func validateOrchestrationPlan(c *gin.Context, plan agnetOrchestrationPlan) bool
return true 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) { func AgnetCreateDeployment(c *gin.Context) {
var req agnetDeploymentRequest var req agnetDeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -414,6 +558,7 @@ func AgnetCreateDeployment(c *gin.Context) {
return return
} }
plan := req.Plan plan := req.Plan
applyAuthenticatedManagerUserContext(c, &plan)
if !validateOrchestrationPlan(c, plan) { if !validateOrchestrationPlan(c, plan) {
return return
} }
@@ -432,8 +577,9 @@ func AgnetCreateDeployment(c *gin.Context) {
EventID: "evt_" + common.GetUUID()[:12], EventID: "evt_" + common.GetUUID()[:12],
Event: "deployment.accepted", Event: "deployment.accepted",
SchemaVersion: 1, SchemaVersion: 1,
TenantID: plan.Metadata.TenantID, UserID: plan.UserContext.UserID,
ProjectID: plan.Metadata.ProjectID, ChannelID: plan.UserContext.ChannelID,
BindingScope: firstPlanBindingScope(plan),
DeploymentID: deploymentID, DeploymentID: deploymentID,
CorrelationID: plan.Metadata.CorrelationID, CorrelationID: plan.Metadata.CorrelationID,
OccurredAt: now, OccurredAt: now,
@@ -474,16 +620,16 @@ func AgnetGetDeployment(c *gin.Context) {
} }
func AgnetListDeployments(c *gin.Context) { func AgnetListDeployments(c *gin.Context) {
tenantID := strings.TrimSpace(c.Query("tenant_id")) userID := strings.TrimSpace(c.Query("user_id"))
projectID := strings.TrimSpace(c.Query("project_id")) bindingScope := strings.TrimSpace(c.Query("binding_scope"))
items := make([]agnetDeploymentRecord, 0) items := make([]agnetDeploymentRecord, 0)
agnetMu.RLock() agnetMu.RLock()
for _, record := range agnetDeployments { for _, record := range agnetDeployments {
if tenantID != "" && record.Plan.Metadata.TenantID != tenantID { if userID != "" && record.Plan.UserContext.UserID != userID {
continue continue
} }
if projectID != "" && record.Plan.Metadata.ProjectID != projectID { if bindingScope != "" && !planHasBindingScope(record.Plan, bindingScope) {
continue continue
} }
items = append(items, record) items = append(items, record)
@@ -518,8 +664,9 @@ func AgnetStopDeployment(c *gin.Context) {
EventID: "evt_" + common.GetUUID()[:12], EventID: "evt_" + common.GetUUID()[:12],
Event: "instance.phase_changed", Event: "instance.phase_changed",
SchemaVersion: 1, SchemaVersion: 1,
TenantID: record.Plan.Metadata.TenantID, UserID: record.Plan.UserContext.UserID,
ProjectID: record.Plan.Metadata.ProjectID, ChannelID: record.Plan.UserContext.ChannelID,
BindingScope: firstPlanBindingScope(record.Plan),
DeploymentID: deploymentID, DeploymentID: deploymentID,
CorrelationID: record.Plan.Metadata.CorrelationID, CorrelationID: record.Plan.Metadata.CorrelationID,
OccurredAt: agnetNow(), OccurredAt: agnetNow(),
@@ -630,9 +777,9 @@ func AgnetGetDeploymentMetrics(c *gin.Context) {
} }
func AgnetProjectDashboardSnapshot(c *gin.Context) { func AgnetProjectDashboardSnapshot(c *gin.Context) {
projectID := strings.TrimSpace(c.Param("project_id")) bindingScope := strings.TrimSpace(c.Param("project_id"))
if projectID == "" { if bindingScope == "" {
agnetError(c, "POLICY_REJECTED", "project_id is required") agnetError(c, "POLICY_REJECTED", "binding_scope is required")
return return
} }
@@ -641,7 +788,7 @@ func AgnetProjectDashboardSnapshot(c *gin.Context) {
stopped := 0 stopped := 0
agnetMu.RLock() agnetMu.RLock()
for _, record := range agnetDeployments { for _, record := range agnetDeployments {
if record.Plan.Metadata.ProjectID != projectID { if !planHasBindingScope(record.Plan, bindingScope) {
continue continue
} }
if record.Status == "accepted" { if record.Status == "accepted" {
@@ -657,7 +804,7 @@ func AgnetProjectDashboardSnapshot(c *gin.Context) {
agnetMu.RUnlock() agnetMu.RUnlock()
common.ApiSuccess(c, gin.H{ common.ApiSuccess(c, gin.H{
"project_id": projectID, "binding_scope": bindingScope,
"active_instances": active, "active_instances": active,
"phase_distribution": gin.H{"pending": pending, "stopped": stopped}, "phase_distribution": gin.H{"pending": pending, "stopped": stopped},
"failure_rate_1h": 0, "failure_rate_1h": 0,
@@ -703,8 +850,8 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
snapshots = append(snapshots, agnetSKSnapshot{ snapshots = append(snapshots, agnetSKSnapshot{
SnapshotID: "sks_" + common.GetUUID()[:12], SnapshotID: "sks_" + common.GetUUID()[:12],
DeploymentID: deploymentID, DeploymentID: deploymentID,
TenantID: record.Plan.Metadata.TenantID, UserID: record.Plan.UserContext.UserID,
ProjectID: record.Plan.Metadata.ProjectID, BindingScope: firstPlanBindingScope(record.Plan),
SourceType: sourceType, SourceType: sourceType,
SourceRef: sourceRef, SourceRef: sourceRef,
ResolvedAt: now, ResolvedAt: now,
@@ -716,8 +863,9 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
EventID: "evt_" + common.GetUUID()[:12], EventID: "evt_" + common.GetUUID()[:12],
Event: "sk_snapshot_refreshed", Event: "sk_snapshot_refreshed",
SchemaVersion: 1, SchemaVersion: 1,
TenantID: record.Plan.Metadata.TenantID, UserID: record.Plan.UserContext.UserID,
ProjectID: record.Plan.Metadata.ProjectID, ChannelID: record.Plan.UserContext.ChannelID,
BindingScope: firstPlanBindingScope(record.Plan),
DeploymentID: deploymentID, DeploymentID: deploymentID,
CorrelationID: record.Plan.Metadata.CorrelationID, CorrelationID: record.Plan.Metadata.CorrelationID,
OccurredAt: now, OccurredAt: now,
@@ -748,19 +896,25 @@ func AgnetListSKSnapshots(c *gin.Context) {
} }
func AgnetListAuditLogs(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) items := make([]gin.H, 0)
agnetMu.RLock() agnetMu.RLock()
for deploymentID, events := range agnetEvents { for deploymentID, events := range agnetEvents {
for _, event := range events { for _, event := range events {
if projectID != "" && event.ProjectID != projectID { if userID != "" && event.UserID != userID {
continue
}
if bindingScope != "" && event.BindingScope != bindingScope {
continue continue
} }
items = append(items, gin.H{ items = append(items, gin.H{
"actor": "agnet_control_plane", "actor": "agnet_control_plane",
"action": event.Event, "action": event.Event,
"resource": deploymentID, "resource": deploymentID,
"tenant_id": event.TenantID, "user_id": event.UserID,
"channel_id": event.ChannelID,
"binding_scope": event.BindingScope,
"request_id": agnetRequestID(c), "request_id": agnetRequestID(c),
"correlation_id": event.CorrelationID, "correlation_id": event.CorrelationID,
"result": "ok", "result": "ok",
+87 -13
View File
@@ -43,6 +43,28 @@ func baseAgnetResourceGrantPlan() agnetOrchestrationPlan {
MaxCostUSD: 10, MaxCostUSD: 10,
MaxDurationSec: 3600, 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{ Agents: []agnetAgentPlan{
{ {
RoleTemplate: "builder", RoleTemplate: "builder",
@@ -53,8 +75,8 @@ func baseAgnetResourceGrantPlan() agnetOrchestrationPlan {
GrantID: "grant-git-builder", GrantID: "grant-git-builder",
ResourceID: "res-git-main", ResourceID: "res-git-main",
ResourceType: agnetResourceGit, ResourceType: agnetResourceGit,
TenantID: "tenant-p1", UserID: "user-p1",
ProjectID: "project-p1", BindingScope: "https://example.invalid/acme/project.git#main",
TargetRole: "builder", TargetRole: "builder",
TargetAgentRef: "agent-builder-1", TargetAgentRef: "agent-builder-1",
PermissionScope: []string{"repo:read", "repo:write:feature-branches"}, PermissionScope: []string{"repo:read", "repo:write:feature-branches"},
@@ -67,7 +89,7 @@ func baseAgnetResourceGrantPlan() agnetOrchestrationPlan {
"repo_url": "https://example.invalid/acme/project.git", "repo_url": "https://example.invalid/acme/project.git",
}, },
Status: agnetGrantStatusActive, 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{ Audit: map[string]string{
"created_by": "manager-test", "created_by": "manager-test",
}, },
@@ -76,8 +98,8 @@ func baseAgnetResourceGrantPlan() agnetOrchestrationPlan {
GrantID: "grant-doc-builder", GrantID: "grant-doc-builder",
ResourceID: "res-doc-plan", ResourceID: "res-doc-plan",
ResourceType: agnetResourceProjectDoc, ResourceType: agnetResourceProjectDoc,
TenantID: "tenant-p1", UserID: "user-p1",
ProjectID: "project-p1", BindingScope: "docs/heicode.md",
TargetRole: "builder", TargetRole: "builder",
TargetAgentRef: "agent-builder-1", TargetAgentRef: "agent-builder-1",
PermissionScope: []string{"doc:read"}, PermissionScope: []string{"doc:read"},
@@ -91,8 +113,6 @@ func baseAgnetResourceGrantPlan() agnetOrchestrationPlan {
}, },
Constraints: agnetConstraints{AllowedModelIDs: []string{"gpt-resource-test"}}, Constraints: agnetConstraints{AllowedModelIDs: []string{"gpt-resource-test"}},
Metadata: agnetMetadata{ Metadata: agnetMetadata{
TenantID: "tenant-p1",
ProjectID: "project-p1",
CorrelationID: "corr-p1-resource-grant", CorrelationID: "corr-p1-resource-grant",
}, },
} }
@@ -109,7 +129,6 @@ func postAgnetCreateDeployment(t *testing.T, plan agnetOrchestrationPlan) (*http
ctx, _ := gin.CreateTestContext(recorder) ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/deployments", strings.NewReader(string(body))) 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("Content-Type", "application/json")
ctx.Request.Header.Set("X-Tenant-Id", plan.Metadata.TenantID)
AgnetCreateDeployment(ctx) AgnetCreateDeployment(ctx)
@@ -137,12 +156,16 @@ func TestAgnetCreateDeploymentAcceptsP1ResourceGrantModel(t *testing.T) {
grants := stored.Plan.Agents[0].ResourceGrants grants := stored.Plan.Agents[0].ResourceGrants
require.Len(t, grants, 2) require.Len(t, grants, 2)
require.Equal(t, agnetResourceGit, grants[0].ResourceType) require.Equal(t, agnetResourceGit, grants[0].ResourceType)
require.Equal(t, "tenant-p1", grants[0].TenantID) require.Equal(t, "user-p1", grants[0].UserID)
require.Equal(t, "project-p1", grants[0].ProjectID) require.Equal(t, "https://example.invalid/acme/project.git#main", grants[0].BindingScope)
require.Equal(t, "builder", grants[0].TargetRole) require.Equal(t, "builder", grants[0].TargetRole)
require.Equal(t, "agent-builder-1", grants[0].TargetAgentRef) 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.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) { func TestAgnetCreateDeploymentRejectsPlaintextResourceGrantCredentialFields(t *testing.T) {
@@ -169,10 +192,22 @@ func TestAgnetCreateDeploymentRejectsResourceGrantWithoutSecretRef(t *testing.T)
require.Empty(t, agnetDeployments) require.Empty(t, agnetDeployments)
} }
func TestAgnetCreateDeploymentRejectsCrossTenantResourceGrant(t *testing.T) { func TestAgnetCreateDeploymentRejectsMissingUserContext(t *testing.T) {
resetAgnetControlPlaneState(t) resetAgnetControlPlaneState(t)
plan := baseAgnetResourceGrantPlan() 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) _, envelope := postAgnetCreateDeployment(t, plan)
@@ -181,6 +216,45 @@ func TestAgnetCreateDeploymentRejectsCrossTenantResourceGrant(t *testing.T) {
require.Empty(t, agnetDeployments) 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) { func TestAgnetDeploymentLogsAndMetricsExposeRedactedReadiness(t *testing.T) {
resetAgnetControlPlaneState(t) resetAgnetControlPlaneState(t)
+65 -20
View File
@@ -48,6 +48,7 @@ var secretLikeKeys = map[string]bool{
type resourcePayload struct { type resourcePayload struct {
TenantId string `json:"tenant_id"` TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"` ProjectId string `json:"project_id"`
BindingScope string `json:"binding_scope"`
Name string `json:"name"` Name string `json:"name"`
ResourceType string `json:"resource_type"` ResourceType string `json:"resource_type"`
Provider string `json:"provider"` Provider string `json:"provider"`
@@ -68,6 +69,7 @@ type resourceResponse struct {
UserId int `json:"user_id"` UserId int `json:"user_id"`
TenantId string `json:"tenant_id"` TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"` ProjectId string `json:"project_id"`
BindingScope string `json:"binding_scope"`
Name string `json:"name"` Name string `json:"name"`
ResourceType string `json:"resource_type"` ResourceType string `json:"resource_type"`
Provider string `json:"provider"` Provider string `json:"provider"`
@@ -84,6 +86,7 @@ type resourceResponse struct {
type resourceGrantPayload struct { type resourceGrantPayload struct {
TenantId string `json:"tenant_id"` TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"` ProjectId string `json:"project_id"`
BindingScope string `json:"binding_scope"`
ResourceId int `json:"resource_id"` ResourceId int `json:"resource_id"`
Role string `json:"role"` Role string `json:"role"`
AgnetId string `json:"agnet_id"` AgnetId string `json:"agnet_id"`
@@ -97,6 +100,7 @@ type resourceGrantResponse struct {
UserId int `json:"user_id"` UserId int `json:"user_id"`
TenantId string `json:"tenant_id"` TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"` ProjectId string `json:"project_id"`
BindingScope string `json:"binding_scope"`
ResourceId int `json:"resource_id"` ResourceId int `json:"resource_id"`
Role string `json:"role"` Role string `json:"role"`
AgnetId string `json:"agnet_id"` AgnetId string `json:"agnet_id"`
@@ -112,6 +116,7 @@ type resourceGrantResponse struct {
func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) { func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) {
p.TenantId = strings.TrimSpace(p.TenantId) p.TenantId = strings.TrimSpace(p.TenantId)
p.ProjectId = strings.TrimSpace(p.ProjectId) p.ProjectId = strings.TrimSpace(p.ProjectId)
p.BindingScope = strings.TrimSpace(p.BindingScope)
p.Name = strings.TrimSpace(p.Name) p.Name = strings.TrimSpace(p.Name)
p.ResourceType = strings.ToLower(strings.TrimSpace(p.ResourceType)) p.ResourceType = strings.ToLower(strings.TrimSpace(p.ResourceType))
p.Provider = strings.TrimSpace(p.Provider) p.Provider = strings.TrimSpace(p.Provider)
@@ -119,8 +124,8 @@ func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) {
p.SecretRef = strings.TrimSpace(p.SecretRef) p.SecretRef = strings.TrimSpace(p.SecretRef)
p.Status = strings.ToLower(strings.TrimSpace(p.Status)) p.Status = strings.ToLower(strings.TrimSpace(p.Status))
if p.TenantId == "" { if p.BindingScope == "" {
return p, errors.New("tenant_id required") p.BindingScope = inferResourceBindingScope(p)
} }
if p.Name == "" { if p.Name == "" {
return p, errors.New("name required") return p, errors.New("name required")
@@ -155,16 +160,11 @@ func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) {
func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload, error) { func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload, error) {
p.TenantId = strings.TrimSpace(p.TenantId) p.TenantId = strings.TrimSpace(p.TenantId)
p.ProjectId = strings.TrimSpace(p.ProjectId) p.ProjectId = strings.TrimSpace(p.ProjectId)
p.BindingScope = strings.TrimSpace(p.BindingScope)
p.Role = strings.TrimSpace(p.Role) p.Role = strings.TrimSpace(p.Role)
p.AgnetId = strings.TrimSpace(p.AgnetId) p.AgnetId = strings.TrimSpace(p.AgnetId)
p.Status = strings.ToLower(strings.TrimSpace(p.Status)) 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 { if p.ResourceId <= 0 {
return p, errors.New("resource_id required") return p, errors.New("resource_id required")
} }
@@ -192,6 +192,21 @@ func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload
return p, nil 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 { func sortedResourceTypes() []string {
types := make([]string, 0, len(allowedResourceTypes)) types := make([]string, 0, len(allowedResourceTypes))
for resourceType := range allowedResourceTypes { for resourceType := range allowedResourceTypes {
@@ -260,6 +275,7 @@ func resourceToResponse(resource model.ResourceBinding) resourceResponse {
UserId: resource.UserId, UserId: resource.UserId,
TenantId: resource.TenantId, TenantId: resource.TenantId,
ProjectId: resource.ProjectId, ProjectId: resource.ProjectId,
BindingScope: resource.BindingScope,
Name: resource.Name, Name: resource.Name,
ResourceType: resource.ResourceType, ResourceType: resource.ResourceType,
Provider: resource.Provider, Provider: resource.Provider,
@@ -280,6 +296,7 @@ func resourceGrantToResponse(grant model.ResourceGrant, resource *model.Resource
UserId: grant.UserId, UserId: grant.UserId,
TenantId: grant.TenantId, TenantId: grant.TenantId,
ProjectId: grant.ProjectId, ProjectId: grant.ProjectId,
BindingScope: grant.BindingScope,
ResourceId: grant.ResourceId, ResourceId: grant.ResourceId,
Role: grant.Role, Role: grant.Role,
AgnetId: grant.AgnetId, AgnetId: grant.AgnetId,
@@ -306,6 +323,9 @@ func ListResources(c *gin.Context) {
if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" { if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" {
query = query.Where("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 != "" { if resourceType := strings.TrimSpace(c.Query("resource_type")); resourceType != "" {
query = query.Where("resource_type = ?", strings.ToLower(resourceType)) query = query.Where("resource_type = ?", strings.ToLower(resourceType))
} }
@@ -342,6 +362,7 @@ func CreateResource(c *gin.Context) {
UserId: userId, UserId: userId,
TenantId: payload.TenantId, TenantId: payload.TenantId,
ProjectId: payload.ProjectId, ProjectId: payload.ProjectId,
BindingScope: payload.BindingScope,
Name: payload.Name, Name: payload.Name,
ResourceType: payload.ResourceType, ResourceType: payload.ResourceType,
Provider: payload.Provider, Provider: payload.Provider,
@@ -387,6 +408,7 @@ func UpdateResource(c *gin.Context) {
} }
resource.TenantId = payload.TenantId resource.TenantId = payload.TenantId
resource.ProjectId = payload.ProjectId resource.ProjectId = payload.ProjectId
resource.BindingScope = payload.BindingScope
resource.Name = payload.Name resource.Name = payload.Name
resource.ResourceType = payload.ResourceType resource.ResourceType = payload.ResourceType
resource.Provider = payload.Provider resource.Provider = payload.Provider
@@ -459,15 +481,18 @@ func UpsertResourceSecret(c *gin.Context) {
} }
func resourceSecretPath(resource model.ResourceBinding) string { func resourceSecretPath(resource model.ResourceBinding) string {
projectId := resource.ProjectId scope := resource.BindingScope
if strings.TrimSpace(projectId) == "" { if strings.TrimSpace(scope) == "" {
projectId = "_tenant" scope = resource.ExternalId
}
if strings.TrimSpace(scope) == "" {
scope = resource.Name
} }
return strings.Join([]string{ return strings.Join([]string{
"tenants", "users",
sanitizeSecretPathSegment(resource.TenantId), fmt.Sprintf("%d", resource.UserId),
"projects", "bindings",
sanitizeSecretPathSegment(projectId), sanitizeSecretPathSegment(scope),
"resources", "resources",
fmt.Sprintf("%d", resource.Id), fmt.Sprintf("%d", resource.Id),
}, "/") }, "/")
@@ -521,6 +546,9 @@ func ListResourceGrants(c *gin.Context) {
if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" { if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" {
query = query.Where("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 != "" { if agnetId := strings.TrimSpace(c.Query("agnet_id")); agnetId != "" {
query = query.Where("agnet_id = ?", agnetId) query = query.Where("agnet_id = ?", agnetId)
} }
@@ -548,11 +576,12 @@ func CreateResourceGrant(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
resource, err := findGrantResource(userId, payload.ResourceId, payload.TenantId) resource, err := findGrantResource(userId, payload.ResourceId, payload.BindingScope)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
payload = inheritResourceGrantScope(payload, resource)
permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload) permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
@@ -562,6 +591,7 @@ func CreateResourceGrant(c *gin.Context) {
UserId: userId, UserId: userId,
TenantId: payload.TenantId, TenantId: payload.TenantId,
ProjectId: payload.ProjectId, ProjectId: payload.ProjectId,
BindingScope: payload.BindingScope,
ResourceId: payload.ResourceId, ResourceId: payload.ResourceId,
Role: payload.Role, Role: payload.Role,
AgnetId: payload.AgnetId, AgnetId: payload.AgnetId,
@@ -597,11 +627,12 @@ func UpdateResourceGrant(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
resource, err := findGrantResource(userId, payload.ResourceId, payload.TenantId) resource, err := findGrantResource(userId, payload.ResourceId, payload.BindingScope)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
payload = inheritResourceGrantScope(payload, resource)
permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload) permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
@@ -609,6 +640,7 @@ func UpdateResourceGrant(c *gin.Context) {
} }
grant.TenantId = payload.TenantId grant.TenantId = payload.TenantId
grant.ProjectId = payload.ProjectId grant.ProjectId = payload.ProjectId
grant.BindingScope = payload.BindingScope
grant.ResourceId = payload.ResourceId grant.ResourceId = payload.ResourceId
grant.Role = payload.Role grant.Role = payload.Role
grant.AgnetId = payload.AgnetId grant.AgnetId = payload.AgnetId
@@ -654,7 +686,20 @@ func marshalResourceGrantPayloadJSON(payload resourceGrantPayload) (string, stri
return permissionScope, constraints, nil 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 var resource model.ResourceBinding
if err := model.DB.Where("id = ? AND user_id = ?", resourceId, userId).First(&resource).Error; err != nil { if err := model.DB.Where("id = ? AND user_id = ?", resourceId, userId).First(&resource).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -662,8 +707,8 @@ func findGrantResource(userId int, resourceId int, tenantId string) (model.Resou
} }
return resource, err return resource, err
} }
if resource.TenantId != tenantId { if bindingScope != "" && resource.BindingScope != "" && resource.BindingScope != bindingScope {
return resource, errors.New("resource tenant_id does not match grant tenant_id") return resource, errors.New("resource binding_scope does not match grant binding_scope")
} }
return resource, nil return resource, nil
} }
+23 -29
View File
@@ -60,13 +60,12 @@ func performResourceRequestWithRoute(handler gin.HandlerFunc, userID int, method
func TestCreateResourceStoresMetadataAndSecretRefOnly(t *testing.T) { func TestCreateResourceStoresMetadataAndSecretRefOnly(t *testing.T) {
db := setupResourceControllerTestDB(t) db := setupResourceControllerTestDB(t)
body := `{ body := `{
"tenant_id":"tenant-a", "binding_scope":"https://example.com/org/repo#main",
"project_id":"project-a",
"name":"Project repository", "name":"Project repository",
"resource_type":"git", "resource_type":"git",
"provider":"github", "provider":"github",
"external_id":"https://example.com/org/repo", "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":["."]}, "metadata":{"repo_url":"https://example.com/org/repo","ref":"main","allowed_paths":["."]},
"permission_scope":{"actions":["read","write"]}, "permission_scope":{"actions":["read","write"]},
"constraints":{"environment":"dev"} "constraints":{"environment":"dev"}
@@ -75,12 +74,14 @@ func TestCreateResourceStoresMetadataAndSecretRefOnly(t *testing.T) {
w := performResourceRequest(CreateResource, 7, http.MethodPost, "/", body) w := performResourceRequest(CreateResource, 7, http.MethodPost, "/", body)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"success":true`) 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 var resource model.ResourceBinding
require.NoError(t, db.First(&resource).Error) require.NoError(t, db.First(&resource).Error)
require.Equal(t, "git", resource.ResourceType) 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.Metadata, "token")
require.NotContains(t, resource.PermissionScope, "token") require.NotContains(t, resource.PermissionScope, "token")
require.NotContains(t, resource.Constraints, "token") require.NotContains(t, resource.Constraints, "token")
@@ -89,11 +90,10 @@ func TestCreateResourceStoresMetadataAndSecretRefOnly(t *testing.T) {
func TestCreateResourceRejectsPlaintextSecretKeys(t *testing.T) { func TestCreateResourceRejectsPlaintextSecretKeys(t *testing.T) {
setupResourceControllerTestDB(t) setupResourceControllerTestDB(t)
body := `{ body := `{
"tenant_id":"tenant-a",
"name":"Cloud account", "name":"Cloud account",
"resource_type":"cloud_account", "resource_type":"cloud_account",
"metadata":{"account_id":"sub-1","access_key":"do-not-store"}, "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) 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") require.Contains(t, w.Body.String(), "plaintext secrets are not allowed")
} }
func TestCreateResourceGrantAssignsTenantProjectRoleAgnet(t *testing.T) { func TestCreateResourceGrantAssignsBoundResourceToRoleAgnet(t *testing.T) {
db := setupResourceControllerTestDB(t) db := setupResourceControllerTestDB(t)
resource := model.ResourceBinding{ resource := model.ResourceBinding{
UserId: 7, UserId: 7,
TenantId: "tenant-a", BindingScope: "https://example.com/sk.git#main",
ProjectId: "project-a",
Name: "SK repo", Name: "SK repo",
ResourceType: "sk", ResourceType: "sk",
Provider: "git", Provider: "git",
SecretRef: "vault://tenant-a/sk/repo", SecretRef: "vault://secret/resources/sk-repo",
Metadata: `{"repo_url":"https://example.com/sk.git"}`, Metadata: `{"repo_url":"https://example.com/sk.git"}`,
Status: "active", Status: "active",
} }
require.NoError(t, db.Create(&resource).Error) require.NoError(t, db.Create(&resource).Error)
body := fmt.Sprintf(`{ body := fmt.Sprintf(`{
"tenant_id":"tenant-a", "binding_scope":"https://example.com/sk.git#main",
"project_id":"project-a",
"resource_id":%d, "resource_id":%d,
"role":"developer", "role":"developer",
"agnet_id":"agnet-dev-1", "agnet_id":"agnet-dev-1",
@@ -130,25 +128,23 @@ func TestCreateResourceGrantAssignsTenantProjectRoleAgnet(t *testing.T) {
w := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", body) w := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", body)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"success":true`) require.Contains(t, w.Body.String(), `"success":true`)
require.Contains(t, w.Body.String(), `"tenant_id":"tenant-a"`) require.Contains(t, w.Body.String(), `"binding_scope":"https://example.com/sk.git#main"`)
require.Contains(t, w.Body.String(), `"project_id":"project-a"`)
require.Contains(t, w.Body.String(), `"role":"developer"`) require.Contains(t, w.Body.String(), `"role":"developer"`)
require.Contains(t, w.Body.String(), `"agnet_id":"agnet-dev-1"`) require.Contains(t, w.Body.String(), `"agnet_id":"agnet-dev-1"`)
var grant model.ResourceGrant var grant model.ResourceGrant
require.NoError(t, db.First(&grant).Error) require.NoError(t, db.First(&grant).Error)
require.Equal(t, resource.Id, grant.ResourceId) require.Equal(t, resource.Id, grant.ResourceId)
require.Equal(t, "tenant-a", grant.TenantId) require.Equal(t, "https://example.com/sk.git#main", grant.BindingScope)
require.Equal(t, "project-a", grant.ProjectId)
require.Equal(t, "developer", grant.Role) require.Equal(t, "developer", grant.Role)
require.Equal(t, "agnet-dev-1", grant.AgnetId) require.Equal(t, "agnet-dev-1", grant.AgnetId)
} }
func TestCreateResourceGrantRejectsCrossTenantResource(t *testing.T) { func TestCreateResourceGrantRejectsMismatchedBindingScope(t *testing.T) {
db := setupResourceControllerTestDB(t) db := setupResourceControllerTestDB(t)
resource := model.ResourceBinding{ resource := model.ResourceBinding{
UserId: 7, UserId: 7,
TenantId: "tenant-a", BindingScope: "azure-vm-prod",
Name: "VM", Name: "VM",
ResourceType: "cloud_resource", ResourceType: "cloud_resource",
Status: "active", Status: "active",
@@ -156,8 +152,7 @@ func TestCreateResourceGrantRejectsCrossTenantResource(t *testing.T) {
require.NoError(t, db.Create(&resource).Error) require.NoError(t, db.Create(&resource).Error)
body := fmt.Sprintf(`{ body := fmt.Sprintf(`{
"tenant_id":"tenant-b", "binding_scope":"azure-vm-dev",
"project_id":"project-a",
"resource_id":%d, "resource_id":%d,
"role":"operator", "role":"operator",
"agnet_id":"agnet-ops-1" "agnet_id":"agnet-ops-1"
@@ -166,15 +161,14 @@ func TestCreateResourceGrantRejectsCrossTenantResource(t *testing.T) {
w := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", body) w := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", body)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"success":false`) 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) { func TestUpsertResourceSecretWritesOpenBaoAndStoresOnlySecretRef(t *testing.T) {
db := setupResourceControllerTestDB(t) db := setupResourceControllerTestDB(t)
resource := model.ResourceBinding{ resource := model.ResourceBinding{
UserId: 7, UserId: 7,
TenantId: "tenant-a", BindingScope: "github-org-repo-main",
ProjectId: "project-a",
Name: "GitHub", Name: "GitHub",
ResourceType: "git", ResourceType: "git",
Provider: "github", Provider: "github",
@@ -186,7 +180,7 @@ func TestUpsertResourceSecretWritesOpenBaoAndStoresOnlySecretRef(t *testing.T) {
var writtenBody map[string]map[string]any var writtenBody map[string]map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method) 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.Equal(t, "manager-token", r.Header.Get("X-Vault-Token"))
require.NoError(t, common.DecodeJson(r.Body, &writtenBody)) require.NoError(t, common.DecodeJson(r.Body, &writtenBody))
writtenPath = r.URL.Path writtenPath = r.URL.Path
@@ -202,14 +196,14 @@ func TestUpsertResourceSecretWritesOpenBaoAndStoresOnlySecretRef(t *testing.T) {
w := performResourceRequestWithRoute(UpsertResourceSecret, 7, http.MethodPost, "/:id/secret", "/1/secret", body) w := performResourceRequestWithRoute(UpsertResourceSecret, 7, http.MethodPost, "/:id/secret", "/1/secret", body)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"success":true`) 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.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"]) require.Equal(t, "do-not-echo", writtenBody["data"]["access_token"])
var stored model.ResourceBinding var stored model.ResourceBinding
require.NoError(t, db.First(&stored, resource.Id).Error) 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") require.NotContains(t, stored.Metadata, "do-not-echo")
} }
@@ -217,7 +211,7 @@ func TestUpsertResourceSecretRequiresSecretStoreToken(t *testing.T) {
db := setupResourceControllerTestDB(t) db := setupResourceControllerTestDB(t)
resource := model.ResourceBinding{ resource := model.ResourceBinding{
UserId: 7, UserId: 7,
TenantId: "tenant-a", BindingScope: "github",
Name: "GitHub", Name: "GitHub",
ResourceType: "git", ResourceType: "git",
Status: "active", Status: "active",
+8 -6
View File
@@ -1,13 +1,14 @@
package model package model
// ResourceBinding is the Manager-side resource record described by docs/plan.md P1. // 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. // plaintext credentials must never be stored here.
type ResourceBinding struct { type ResourceBinding struct {
Id int `json:"id"` Id int `json:"id"`
UserId int `json:"user_id" gorm:"index;not null"` 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"` 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"` Name string `json:"name" gorm:"type:varchar(128);not null"`
ResourceType string `json:"resource_type" gorm:"type:varchar(32);index;not null"` ResourceType string `json:"resource_type" gorm:"type:varchar(32);index;not null"`
Provider string `json:"provider" gorm:"type:varchar(64);default:'custom'"` 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"` UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"`
} }
// ResourceGrant assigns a ResourceBinding to a project, role, and child Agnet. // ResourceGrant assigns a ResourceBinding to a role and child Agnet.
// It is the auditable Manager expression of "tenant/project grants resource to role". // It is the auditable Manager expression of "user grants bound resource to role".
type ResourceGrant struct { type ResourceGrant struct {
Id int `json:"id"` Id int `json:"id"`
UserId int `json:"user_id" gorm:"index;not null"` 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;not null"` 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"` ResourceId int `json:"resource_id" gorm:"index;not null"`
Role string `json:"role" gorm:"type:varchar(128);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"` 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[] sk_sources?: AgnetSKSource[]
runtime_execution?: AgnetRuntimeExecution runtime_execution?: AgnetRuntimeExecution
sk_access_policy?: AgnetSKAccessPolicy 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 = { export type AgnetBudget = {
@@ -45,12 +54,45 @@ export type AgnetBudget = {
max_duration_sec: number 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 = { export type AgnetConstraints = {
/** Runtime model allow-list for Agnet deployments; not a NewAPI billing map. */
allowed_model_ids?: string[] allowed_model_ids?: string[]
} }
export type AgnetOrchestrationMetadata = { export type AgnetOrchestrationMetadata = {
/**
* Compatibility field for Agnet routing scope; UI treats this as user scope,
* not billing tenant.
*/
tenant_id: string tenant_id: string
/**
* Compatibility field for Agnet routing scope; UI treats this as resource
* scope, not project control.
*/
project_id: string project_id: string
correlation_id: string correlation_id: string
} }
@@ -61,6 +103,9 @@ export type AgnetOrchestrationPlan = {
objective: string objective: string
risk_level: 'low' | 'medium' | 'high' risk_level: 'low' | 'medium' | 'high'
budget: AgnetBudget budget: AgnetBudget
user_context: AgnetUserContext
billing_context?: AgnetBillingContext
agent_runtime?: AgnetAgentRuntime
agents: AgnetAgentPlan[] agents: AgnetAgentPlan[]
constraints: AgnetConstraints constraints: AgnetConstraints
metadata: AgnetOrchestrationMetadata metadata: AgnetOrchestrationMetadata
@@ -73,7 +118,11 @@ export type AgnetCreateDeploymentBody = {
export type AgnetCreateDeploymentResult = { export type AgnetCreateDeploymentResult = {
deployment_id: string deployment_id: string
status: 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 = { export type AgnetDeployment = {
@@ -161,35 +210,33 @@ export async function createAgnetDeployment(
} }
export async function getAgnetDeploymentEvents(deploymentId: string) { export async function getAgnetDeploymentEvents(deploymentId: string) {
const res = await api.get<ApiEnvelope<{ items?: Array<Record<string, unknown>> }>>( const res = await api.get<
`/api/agnet/deployments/${deploymentId}/events` ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
) >(`/api/agnet/deployments/${deploymentId}/events`)
return res.data?.data?.items ?? [] return res.data?.data?.items ?? []
} }
export async function getAgnetAuditLogs() { export async function getAgnetAuditLogs() {
const res = await api.get<ApiEnvelope<{ items?: Array<Record<string, unknown>> }>>( const res = await api.get<
'/api/agnet/audit-logs' ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
) >('/api/agnet/audit-logs')
return res.data?.data?.items ?? [] return res.data?.data?.items ?? []
} }
export async function getAgnetSnapshots(deploymentId: string) { export async function getAgnetSnapshots(deploymentId: string) {
const res = await api.get<ApiEnvelope<{ items?: Array<Record<string, unknown>> }>>( const res = await api.get<
`/api/agnet/deployments/${deploymentId}/sk-snapshots`, ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
{ >(`/api/agnet/deployments/${deploymentId}/sk-snapshots`, {
skipBusinessError: true, skipBusinessError: true,
skipErrorHandler: true, skipErrorHandler: true,
} as Record<string, unknown> } as Record<string, unknown>)
)
if (!res.data?.success) return [] if (!res.data?.success) return []
return res.data?.data?.items ?? [] return res.data?.data?.items ?? []
} }
export async function listGitSources(): Promise<GitSource[]> { export async function listGitSources(): Promise<GitSource[]> {
const res = await api.get<ApiEnvelope<{ items?: GitSource[] }>>( const res =
'/api/git-sources/' await api.get<ApiEnvelope<{ items?: GitSource[] }>>('/api/git-sources/')
)
return res.data?.data?.items ?? [] return res.data?.data?.items ?? []
} }
@@ -23,6 +23,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { useAuthStore } from '@/stores/auth-store'
import { import {
createAgnetDeployment, createAgnetDeployment,
type AgnetAgentPlan, type AgnetAgentPlan,
@@ -64,8 +65,7 @@ function buildAgent(row: AgentFormRow): AgnetAgentPlan {
const profileId = row.profile_id.trim() const profileId = row.profile_id.trim()
const net = row.network_policy_ref.trim() const net = row.network_policy_ref.trim()
const clouds = splitComma(row.cloud_principals) const clouds = splitComma(row.cloud_principals)
const hasRuntime = const hasRuntime = profileId !== '' || net !== '' || clouds.length > 0
profileId !== '' || net !== '' || clouds.length > 0
if (hasRuntime && profileId === '') { if (hasRuntime && profileId === '') {
throw new Error('profile_required_for_runtime') throw new Error('profile_required_for_runtime')
} }
@@ -135,6 +135,7 @@ export function CreateAgnetDeploymentSheet({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const currentUser = useAuthStore((state) => state.auth.user)
const [templateHint, setTemplateHint] = useState('agile_min') const [templateHint, setTemplateHint] = useState('agile_min')
const [objective, setObjective] = useState('') const [objective, setObjective] = useState('')
@@ -144,8 +145,8 @@ export function CreateAgnetDeploymentSheet({
const [maxTokens, setMaxTokens] = useState(120_000) const [maxTokens, setMaxTokens] = useState(120_000)
const [maxCost, setMaxCost] = useState(8) const [maxCost, setMaxCost] = useState(8)
const [maxDurationSec, setMaxDurationSec] = useState(3600) const [maxDurationSec, setMaxDurationSec] = useState(3600)
const [tenantId, setTenantId] = useState('ten_local') const [userScopeRef, setUserScopeRef] = useState('user_local')
const [projectId, setProjectId] = useState('prj_default') const [resourceScopeRef, setResourceScopeRef] = useState('repo_default')
const [correlationId, setCorrelationId] = useState('') const [correlationId, setCorrelationId] = useState('')
const [allowedModels, setAllowedModels] = useState('') const [allowedModels, setAllowedModels] = useState('')
const [agents, setAgents] = useState<AgentFormRow[]>([emptyAgent()]) const [agents, setAgents] = useState<AgentFormRow[]>([emptyAgent()])
@@ -163,8 +164,8 @@ export function CreateAgnetDeploymentSheet({
setMaxTokens(120_000) setMaxTokens(120_000)
setMaxCost(8) setMaxCost(8)
setMaxDurationSec(3600) setMaxDurationSec(3600)
setTenantId('ten_local') setUserScopeRef('user_local')
setProjectId('prj_default') setResourceScopeRef('repo_default')
setCorrelationId(crypto.randomUUID()) setCorrelationId(crypto.randomUUID())
setAllowedModels('') setAllowedModels('')
setAgents([emptyAgent()]) setAgents([emptyAgent()])
@@ -183,6 +184,13 @@ export function CreateAgnetDeploymentSheet({
} }
throw e 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 = { const plan: AgnetOrchestrationPlan = {
intent_id: intentId, intent_id: intentId,
@@ -194,13 +202,32 @@ export function CreateAgnetDeploymentSheet({
max_cost_usd: maxCost, max_cost_usd: maxCost,
max_duration_sec: maxDurationSec, 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, agents: agentPlans,
constraints: { constraints: {
allowed_model_ids: allowed.length > 0 ? allowed : undefined, allowed_model_ids: allowed.length > 0 ? allowed : undefined,
}, },
metadata: { metadata: {
tenant_id: tenantId.trim(), tenant_id: userScopeRef.trim(),
project_id: projectId.trim(), project_id: resourceScopeRef.trim(),
correlation_id: correlationId.trim(), correlation_id: correlationId.trim(),
}, },
} }
@@ -239,16 +266,16 @@ export function CreateAgnetDeploymentSheet({
return ( return (
templateHint.trim() && templateHint.trim() &&
objective.trim() && objective.trim() &&
tenantId.trim() && userScopeRef.trim() &&
projectId.trim() && resourceScopeRef.trim() &&
correlationId.trim() && correlationId.trim() &&
agents.every((a) => a.role_template.trim() && a.goal.trim()) agents.every((a) => a.role_template.trim() && a.goal.trim())
) )
}, [ }, [
templateHint, templateHint,
objective, objective,
tenantId, userScopeRef,
projectId, resourceScopeRef,
correlationId, correlationId,
agents, agents,
]) ])
@@ -355,18 +382,18 @@ export function CreateAgnetDeploymentSheet({
</p> </p>
<div className='grid gap-2 sm:grid-cols-2'> <div className='grid gap-2 sm:grid-cols-2'>
<div> <div>
<Label className='text-xs'>tenant_id</Label> <Label className='text-xs'>{t('User scope ref')}</Label>
<Input <Input
value={tenantId} value={userScopeRef}
onChange={(e) => setTenantId(e.target.value)} onChange={(e) => setUserScopeRef(e.target.value)}
className='mt-1 font-mono text-xs' className='mt-1 font-mono text-xs'
/> />
</div> </div>
<div> <div>
<Label className='text-xs'>project_id</Label> <Label className='text-xs'>{t('Resource scope ref')}</Label>
<Input <Input
value={projectId} value={resourceScopeRef}
onChange={(e) => setProjectId(e.target.value)} onChange={(e) => setResourceScopeRef(e.target.value)}
className='mt-1 font-mono text-xs' className='mt-1 font-mono text-xs'
/> />
</div> </div>
@@ -380,7 +407,9 @@ export function CreateAgnetDeploymentSheet({
/> />
</div> </div>
<div className='grid gap-2'> <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 <Input
value={allowedModels} value={allowedModels}
onChange={(e) => setAllowedModels(e.target.value)} onChange={(e) => setAllowedModels(e.target.value)}
@@ -460,7 +489,7 @@ export function CreateAgnetDeploymentSheet({
className='text-sm' className='text-sm'
/> />
<Input <Input
placeholder={t('Default model id')} placeholder={t('Agnet runtime model id')}
value={agent.default_model_id} value={agent.default_model_id}
onChange={(e) => onChange={(e) =>
setAgents((prev) => { 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 ( 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'> <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'> <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> </p>
<h2 className='mt-1 text-xl font-semibold'>{props.title}</h2> <h2 className='mt-1 text-xl font-semibold'>{props.title}</h2>
{props.subtitle && ( {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> </div>
{props.toolbar && ( {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> </header>
{props.children} {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 ( return (
<div className='grid gap-3 sm:grid-cols-2'> <div className='grid gap-3 sm:grid-cols-2'>
{Array.from({ length: rows }).map((_, idx) => ( {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 ( 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'> <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' /> <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() const objective = (dep.orchestration_plan?.objective || '').toLowerCase()
if (objective.includes('production') || objective.includes('critical')) { if (objective.includes('production') || objective.includes('critical')) {
return { label: 'high', tone: 'high' } return { label: 'high', tone: 'high' }
@@ -183,8 +209,15 @@ function describeBudget(dep: AgnetDeployment): string {
return `${agents} agents` return `${agents} agents`
} }
function describeExecutor(dep: AgnetDeployment): string { function describeScope(dep: AgnetDeployment): string {
return dep.orchestration_plan?.metadata?.tenant_id || '—' 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 { function formatRelativeTime(value: string | undefined): string {
@@ -227,6 +260,11 @@ export function AgnetDeploymentsPage() {
const blob = const blob =
`${dep.deployment_id} ${dep.orchestration_plan?.objective || ''} ${ `${dep.deployment_id} ${dep.orchestration_plan?.objective || ''} ${
dep.orchestration_plan?.metadata?.tenant_id || '' dep.orchestration_plan?.metadata?.tenant_id || ''
} ${
dep.orchestration_plan?.agents
?.flatMap((agent) => agent.resource_grants || [])
.map((grant) => grant.binding_scope || '')
.join(' ') || ''
}`.toLowerCase() }`.toLowerCase()
if (!blob.includes(k)) return false if (!blob.includes(k)) return false
} }
@@ -257,7 +295,7 @@ export function AgnetDeploymentsPage() {
<Input <Input
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} 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' className='h-9 w-64 rounded-xl pl-8 text-xs'
/> />
</div> </div>
@@ -325,8 +363,8 @@ export function AgnetDeploymentsPage() {
/> />
<MetaPill <MetaPill
icon={Building2} icon={Building2}
label={t('tenant')} label={t('scope')}
value={describeExecutor(dep)} value={describeScope(dep)}
/> />
</div> </div>
@@ -352,7 +390,10 @@ export function AgnetDeploymentsPage() {
</div> </div>
)} )}
</PageSurface> </PageSurface>
<CreateAgnetDeploymentSheet open={createOpen} onOpenChange={setCreateOpen} /> <CreateAgnetDeploymentSheet
open={createOpen}
onOpenChange={setCreateOpen}
/>
</> </>
) )
} }
@@ -425,13 +466,8 @@ export function AgnetEventsPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{deployments.map((dep) => ( {deployments.map((dep) => (
<SelectItem <SelectItem key={dep.deployment_id} value={dep.deployment_id}>
key={dep.deployment_id} <span className='font-mono text-xs'>{dep.deployment_id}</span>
value={dep.deployment_id}
>
<span className='font-mono text-xs'>
{dep.deployment_id}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -528,7 +564,7 @@ export function AgnetEventsPage() {
export function AgnetAuditPage() { export function AgnetAuditPage() {
const { t } = useTranslation() const { t } = useTranslation()
const [tenant, setTenant] = useState('') const [scope, setScope] = useState('')
const [actor, setActor] = useState('') const [actor, setActor] = useState('')
const [action, setAction] = useState('') const [action, setAction] = useState('')
@@ -541,28 +577,30 @@ export function AgnetAuditPage() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
return data.filter((entry) => { return data.filter((entry) => {
const e = entry as Record<string, unknown> 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 a = String(e.actor || e.user || '').toLowerCase()
const ac = String(e.action || e.event || '').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 (actor && !a.includes(actor.toLowerCase())) return false
if (action && !ac.includes(action.toLowerCase())) return false if (action && !ac.includes(action.toLowerCase())) return false
return true return true
}) })
}, [data, tenant, actor, action]) }, [data, scope, actor, action])
return ( return (
<PageSurface <PageSurface
title={t('Audit')} title={t('Audit')}
subtitle={t( 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={ toolbar={
<> <>
<Input <Input
value={tenant} value={scope}
onChange={(e) => setTenant(e.target.value)} onChange={(e) => setScope(e.target.value)}
placeholder={t('tenant')} placeholder={t('scope')}
className='h-9 w-36 rounded-xl text-xs' className='h-9 w-36 rounded-xl text-xs'
/> />
<Input <Input
@@ -602,7 +640,7 @@ export function AgnetAuditPage() {
</th> </th>
<th className='px-4 py-2 text-left font-semibold'> <th className='px-4 py-2 text-left font-semibold'>
<Building2 className='mr-1 inline h-3.5 w-3.5' /> <Building2 className='mr-1 inline h-3.5 w-3.5' />
{t('tenant')} {t('scope')}
</th> </th>
<th className='px-4 py-2 text-left font-semibold'> <th className='px-4 py-2 text-left font-semibold'>
<Calendar className='mr-1 inline h-3.5 w-3.5' /> <Calendar className='mr-1 inline h-3.5 w-3.5' />
@@ -625,7 +663,9 @@ export function AgnetAuditPage() {
{String(e.actor || e.user || '—')} {String(e.actor || e.user || '—')}
</td> </td>
<td className='px-4 py-2 text-xs'> <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>
<td className='px-4 py-2 text-xs text-muted-foreground'> <td className='px-4 py-2 text-xs text-muted-foreground'>
{formatRelativeTime( {formatRelativeTime(
@@ -837,15 +877,19 @@ export function AgnetSKSourcesPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='project'>{t('Project repository')}</SelectItem> <SelectItem value='project'>
{t('Project repository')}
</SelectItem>
<SelectItem value='sk'>{t('SK repository')}</SelectItem> <SelectItem value='sk'>{t('SK repository')}</SelectItem>
<SelectItem value='combined'>{t('Combined repository')}</SelectItem> <SelectItem value='combined'>
{t('Combined repository')}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div> <div>
<label className='text-xs font-medium text-muted-foreground'> <label className='text-xs font-medium text-muted-foreground'>
tenant_id {t('Owner scope ref')}
</label> </label>
<Input <Input
value={gitForm.tenant_id} value={gitForm.tenant_id}
@@ -909,9 +953,7 @@ export function AgnetSKSourcesPage() {
> >
<div className='flex items-start justify-between gap-3'> <div className='flex items-start justify-between gap-3'>
<div className='min-w-0'> <div className='min-w-0'>
<p className='truncate text-sm font-medium'> <p className='truncate text-sm font-medium'>{src.name}</p>
{src.name}
</p>
<p className='mt-1 truncate font-mono text-[11px] text-muted-foreground'> <p className='mt-1 truncate font-mono text-[11px] text-muted-foreground'>
{src.repo_url} {src.repo_url}
</p> </p>
@@ -1076,7 +1118,7 @@ export function AgnetTemplatesPage() {
return ( return (
<PageSurface <PageSurface
title={t('Templates')} 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'> <div className='grid gap-3 md:grid-cols-2'>
{templates.map((tpl) => ( {templates.map((tpl) => (
@@ -1127,7 +1169,7 @@ export function AgnetAgentsPage() {
dep: dep.deployment_id, dep: dep.deployment_id,
id: `${dep.deployment_id}-${idx}`, id: `${dep.deployment_id}-${idx}`,
role: agent.role_template || '-', role: agent.role_template || '-',
model: agent.default_model_id || '-', runtimeModel: agent.default_model_id || '-',
goal: agent.goal || '-', goal: agent.goal || '-',
runtime: agent.runtime_execution, runtime: agent.runtime_execution,
skPolicy: agent.sk_access_policy, skPolicy: agent.sk_access_policy,
@@ -1139,7 +1181,7 @@ export function AgnetAgentsPage() {
return ( return (
<PageSurface <PageSurface
title={t('Agents')} 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 ? ( {rows.length === 0 ? (
<EmptySurface <EmptySurface
@@ -1154,7 +1196,7 @@ export function AgnetAgentsPage() {
> >
<p className='text-sm font-medium'>{row.role}</p> <p className='text-sm font-medium'>{row.role}</p>
<p className='mt-1 font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground'> <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>
<p className='mt-2 text-sm'>{row.goal}</p> <p className='mt-2 text-sm'>{row.goal}</p>
{runtimeSummary(row.runtime) && ( {runtimeSummary(row.runtime) && (
@@ -1165,8 +1207,7 @@ export function AgnetAgentsPage() {
{row.runtime?.profile_id ? ( {row.runtime?.profile_id ? (
<p className='mt-1'> <p className='mt-1'>
<span className='text-muted-foreground'> <span className='text-muted-foreground'>
{t('Execution profile')} {t('Execution profile')}:{' '}
:{' '}
</span> </span>
<span className='font-mono text-foreground'> <span className='font-mono text-foreground'>
{row.runtime.profile_id} {row.runtime.profile_id}
@@ -1176,8 +1217,7 @@ export function AgnetAgentsPage() {
{(row.runtime?.cloud_principal_refs?.length ?? 0) > 0 ? ( {(row.runtime?.cloud_principal_refs?.length ?? 0) > 0 ? (
<p className='mt-1'> <p className='mt-1'>
<span className='text-muted-foreground'> <span className='text-muted-foreground'>
{t('Cloud principals')} {t('Cloud principals')}:{' '}
:{' '}
</span> </span>
<span className='font-mono text-foreground'> <span className='font-mono text-foreground'>
{row.runtime?.cloud_principal_refs?.join(', ')} {row.runtime?.cloud_principal_refs?.join(', ')}
@@ -1187,8 +1227,7 @@ export function AgnetAgentsPage() {
{row.runtime?.network_policy_ref ? ( {row.runtime?.network_policy_ref ? (
<p className='mt-1'> <p className='mt-1'>
<span className='text-muted-foreground'> <span className='text-muted-foreground'>
{t('Network policy')} {t('Network policy')}:{' '}
:{' '}
</span> </span>
<span className='font-mono text-foreground'> <span className='font-mono text-foreground'>
{row.runtime.network_policy_ref} {row.runtime.network_policy_ref}
@@ -1205,8 +1244,7 @@ export function AgnetAgentsPage() {
{row.skPolicy?.policy_ref ? ( {row.skPolicy?.policy_ref ? (
<p className='mt-1'> <p className='mt-1'>
<span className='text-muted-foreground'> <span className='text-muted-foreground'>
{t('Policy ref')} {t('Policy ref')}:{' '}
:{' '}
</span> </span>
<span className='font-mono text-foreground'> <span className='font-mono text-foreground'>
{row.skPolicy.policy_ref} {row.skPolicy.policy_ref}
@@ -1216,8 +1254,7 @@ export function AgnetAgentsPage() {
{(row.skPolicy?.deny_skill_ids?.length ?? 0) > 0 ? ( {(row.skPolicy?.deny_skill_ids?.length ?? 0) > 0 ? (
<p className='mt-1'> <p className='mt-1'>
<span className='text-muted-foreground'> <span className='text-muted-foreground'>
{t('Denied skills')} {t('Denied skills')}:{' '}
:{' '}
</span> </span>
<span className='font-mono text-foreground'> <span className='font-mono text-foreground'>
{row.skPolicy?.deny_skill_ids?.join(', ')} {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 if (!me?.success || !me.data) return null
const upstreamId = Number(me.data.id)
const roleStr = String(me.data.role || 'user').toLowerCase() const roleStr = String(me.data.role || 'user').toLowerCase()
const role = const role =
roleStr === 'root' ? 100 : roleStr === 'admin' ? 10 : roleStr === 'user' ? 1 : 1 roleStr === 'root' ? 100 : roleStr === 'admin' ? 10 : roleStr === 'user' ? 1 : 1
return { return {
id: 1, id: Number.isFinite(upstreamId) && upstreamId > 0 ? upstreamId : 1,
username: me.data.email || me.data.name || 'heicode-user', username: me.data.email || me.data.name || 'heicode-user',
display_name: me.data.name || me.data.email || 'Heicode User', display_name: me.data.name || me.data.email || 'Heicode User',
email: me.data.email || '', email: me.data.email || '',
+19 -8
View File
@@ -211,12 +211,16 @@
"Inherits deployment defaults": "Inherits deployment defaults", "Inherits deployment defaults": "Inherits deployment defaults",
"Add agent": "Add agent", "Add agent": "Add agent",
"Agent": "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 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)", "Allowed models comma": "Allowed model IDs (comma-separated)",
"Budget caps": "Budget caps", "Budget caps": "Agnet runtime caps",
"Cloud principals comma": "Cloud principals (comma-separated)", "Cloud principals comma": "Cloud principals (comma-separated)",
"Create Agnet deployment": "Create Agnet deployment", "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", "Default model id": "Default model ID",
"Deployment plan": "Deployment plan", "Deployment plan": "Deployment plan",
"Deployment request failed": "Deployment request failed", "Deployment request failed": "Deployment request failed",
@@ -566,7 +570,7 @@
"Cancelled": "Cancelled", "Cancelled": "Cancelled",
"Cancelled at": "Cancelled at", "Cancelled at": "Cancelled at",
"Capture a reusable bundle of models, tags, or endpoints.": "Capture a reusable bundle of models, tags, or endpoints.", "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": "Category Name",
"Category name is required": "Category name is required", "Category name is required": "Category name is required",
"Category name must be less than 50 characters": "Category name must be less than 50 characters", "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 Consumed": "Final Consumed",
"Final cost = base × multiplier when conditions match": "Final cost = base × multiplier when conditions match", "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", "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.", "Fine-tune Midjourney integration and guardrails.": "Fine-tune Midjourney integration and guardrails.",
"Finish Time": "Finish Time", "Finish Time": "Finish Time",
"First/Last Frame to Video": "First/Last Frame to Video", "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 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 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 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 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 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 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": "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", "GitHub": "GitHub",
"No Git sources bound yet": "No Git sources bound yet.", "No Git sources bound yet": "No Git sources bound yet.",
"Project repository": "Project repository", "Project repository": "Project repository",
@@ -2035,7 +2040,7 @@
"Merchant ID": "Merchant ID", "Merchant ID": "Merchant ID",
"Merchant ID is required": "Merchant ID is required", "Merchant ID is required": "Merchant ID is required",
"Message Priority": "Message Priority", "Message Priority": "Message Priority",
"Metadata": "Metadata", "Metadata": "Deployment scope",
"Midjourney": "Midjourney", "Midjourney": "Midjourney",
"MidjourneyPlus": "MidjourneyPlus", "MidjourneyPlus": "MidjourneyPlus",
"Min Top-up": "Min Top-up", "Min Top-up": "Min Top-up",
@@ -3011,6 +3016,7 @@
"Scheduled channel tests": "Scheduled channel tests", "Scheduled channel tests": "Scheduled channel tests",
"Scope": "Scope", "Scope": "Scope",
"Scopes": "Scopes", "Scopes": "Scopes",
"scope": "scope",
"Search": "Search", "Search": "Search",
"Search by name or URL...": "Search by name or URL...", "Search by name or URL...": "Search by name or URL...",
"Search by order number...": "Search by order number...", "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 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 for free with generous limits. No credit card required.": "Start for free with generous limits. No credit card required.",
"Start Time": "Start Time", "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.", "Static page describing the platform.": "Static page describing the platform.",
"Statistical count": "Statistical count", "Statistical count": "Statistical count",
"Statistical quota": "Statistical quota", "Statistical quota": "Statistical quota",
@@ -3337,6 +3344,10 @@
"Templates": "Templates", "Templates": "Templates",
"Templates appended": "Templates appended", "Templates appended": "Templates appended",
"tenant": "tenant", "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 access": "Tenant access",
"Tenant administration": "Tenant administration", "Tenant administration": "Tenant administration",
"Tenant member": "Tenant member", "Tenant member": "Tenant member",
+20 -9
View File
@@ -197,7 +197,7 @@
"After enabling, the plan will be shown to users. Continue?": "启用后套餐将在用户端展示。是否继续?", "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 invalidating, this subscription will be immediately deactivated. Historical records are not affected. Continue?": "作废后该订阅将立即失效,历史记录不受影响。是否继续?",
"After scanning, the binding will complete automatically": "扫描后,绑定将自动完成", "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 *", "Agent ID *": "代理 ID *",
"Agentic development control plane": "智能体研发控制面", "Agentic development control plane": "智能体研发控制面",
"Agents": "Agent", "Agents": "Agent",
@@ -211,12 +211,16 @@
"Inherits deployment defaults": "继承部署默认策略", "Inherits deployment defaults": "继承部署默认策略",
"Add agent": "添加 Agent", "Add agent": "添加 Agent",
"Agent": "Agent", "Agent": "Agent",
"Agent declarations parsed from each Agnet deployment plan.": "从各 Agnet 部署计划中解析的 Agent 声明。",
"Agnet deployment created": "Agnet 部署已创建({{deployment_id}})", "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(逗号分隔)", "Allowed models comma": "允许的模型 ID(逗号分隔)",
"Budget caps": "预算上限", "Budget caps": "Agnet 运行上限",
"Cloud principals comma": "云身份引用(逗号分隔)", "Cloud principals comma": "云身份引用(逗号分隔)",
"Create Agnet deployment": "创建 Agnet 部署", "Create Agnet deployment": "创建 Agnet 部署",
"Create Agnet deployment description": "在一次请求中提交完整编排计划。Manager 负责记录;实际执行在 Agnet。", "Create Agnet deployment description": "向 Agnet 提交编排计划。这里的模型选择是运行策略,不是 NewAPI 计费配置。",
"Default model id": "默认模型 ID", "Default model id": "默认模型 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.": "以卡片形式展示每次 Agnet 编排运行,包含风险、预算、执行者与实时状态。", "Card-based view of every Agnet orchestration run with risk, budget, executor and live status.": "以卡片形式展示每次 Agnet 编排运行,包含风险、运行上限、资源作用域与实时状态。",
"Category Name": "分类名称", "Category Name": "分类名称",
"Category name is required": "分类名称不能为空", "Category name is required": "分类名称不能为空",
"Category name must be less than 50 characters": "分类名称不能超过 50 个字符", "Category name must be less than 50 characters": "分类名称不能超过 50 个字符",
@@ -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": "最终价格乘数 (0.95 = 5% 折扣", "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 集成和防护栏。", "Fine-tune Midjourney integration and guardrails.": "微调 Midjourney 集成和防护栏。",
"Finish Time": "完成时间", "Finish Time": "完成时间",
"First/Last Frame to Video": "首尾生视频", "First/Last Frame to Video": "首尾生视频",
@@ -1650,12 +1655,12 @@
"Git sources subtitle": "在部署参数中把绑定与策略交给 Agnet,在此按部署查看快照锚点(执行权在 Agnet)。", "Git sources subtitle": "在部署参数中把绑定与策略交给 Agnet,在此按部署查看快照锚点(执行权在 Agnet)。",
"Git sources workflow title": "典型配置顺序", "Git sources workflow title": "典型配置顺序",
"Git sources workflow step 1": "绑定团队用于应用代码与交付上下文的 Git 仓库。", "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 3": "为子 Agent 分配云上资源与权限(如独立虚拟机、角色与其他访问边界)。",
"Git sources workflow step 4": "部署子 Agent;部署会把已绑定的来源与策略写入该次运行。", "Git sources workflow step 4": "部署子 Agent;部署会把已绑定的来源与策略写入该次运行。",
"Git sources workflow step 5": "部署完成后,子 Agent 从控制面获知生效的允许 SK 与禁止 SK。", "Git sources workflow step 5": "部署完成后,子 Agent 从控制面获知生效的允许 SK 与禁止 SK。",
"Git-backed SK sources": "基于 Git 的 SK 来源", "Git-backed SK sources": "基于 Git 的 SK 来源",
"Git-backed SK sources description": "先完成仓库与云上能力绑定;每次部署后的不可变快照反映在该租户策略下实际运行的 SK 血缘。", "Git-backed SK sources description": "先完成仓库与云上能力绑定;每次部署后的不可变快照反映在 Resource Grant 策略下实际运行的 SK 血缘。",
"GitHub": "GitHub", "GitHub": "GitHub",
"No Git sources bound yet": "还没有绑定 Git 来源。", "No Git sources bound yet": "还没有绑定 Git 来源。",
"Project repository": "项目仓库", "Project repository": "项目仓库",
@@ -2035,7 +2040,7 @@
"Merchant ID": "商户 ID", "Merchant ID": "商户 ID",
"Merchant ID is required": "商户 ID 为必填项", "Merchant ID is required": "商户 ID 为必填项",
"Message Priority": "消息优先级", "Message Priority": "消息优先级",
"Metadata": "元信息", "Metadata": "部署作用域",
"Midjourney": "Midjourney", "Midjourney": "Midjourney",
"MidjourneyPlus": "MidjourneyPlus", "MidjourneyPlus": "MidjourneyPlus",
"Min Top-up": "最低充值", "Min Top-up": "最低充值",
@@ -3011,6 +3016,7 @@
"Scheduled channel tests": "定期渠道测试", "Scheduled channel tests": "定期渠道测试",
"Scope": "作用域", "Scope": "作用域",
"Scopes": "作用域", "Scopes": "作用域",
"scope": "作用域",
"Search": "搜索", "Search": "搜索",
"Search by name or URL...": "按名称或 URL 搜索...", "Search by name or URL...": "按名称或 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 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": "用户作用域引用",
"Resource scope ref": "资源作用域引用",
"Owner scope ref": "归属作用域引用",
"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": "租户成员",