Make Agnet runs submit resource-scoped work safely

Manager now exposes a Resource Grant manifest and the Agnet control-plane response carries runtime state, agent instances, and permission_manifest so frontend runs can submit bounded resource grants without plaintext credentials.

Constraint: Manager remains the user console while NewAPI stays independent and OpenBao is referenced through secret_ref only.

Rejected: platform-side high-risk approval | client approval is the product boundary; Agnet only validates approval evidence.

Confidence: medium

Scope-risk: moderate

Directive: Do not mix child Agnet runtime model selection with NewAPI billing or expose OpenBao as a public route.

Tested: git diff --check; jq empty locale JSON; go vet ./controller ./model ./router; go test -count=1 ./controller ./model ./router

Not-tested: frontend typecheck/build because local node_modules tooling is absent and user requested builds happen on the VM.

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
gongzhiyong
2026-05-04 18:55:53 +08:00
co-authored by OmX
parent ba02ae5be7
commit bf134dec86
12 changed files with 1624 additions and 505 deletions
@@ -2,7 +2,7 @@
日期:2026-05-04
本文修正 Manager、Agnet 平台、NewAPI 与 OpenBao 之间的运行时边界。若本文与旧文档中 `tenant`、`project` 或平台代理审批描述冲突,以本文为准。
本文修正 Manager、Agnet 平台、NewAPI 与 OpenBao 之间的运行时边界。若本文与旧文档中 `tenant`、`project` 或平台侧批准描述冲突,以本文为准。
## 一、用户输入在哪里
@@ -62,7 +62,7 @@ Manager 可以有本地 user cache,但 canonical user identity 应来自登录
## 三、高危操作审批与 OpenBao 密钥注入
高危操作审批只在客户端完成。用户在客户端明确批准后,Manager/Agnet 平台才可以执行对应动作。
高危操作审批只在客户端完成。用户在客户端明确批准后,Manager/Agnet 平台才可以执行对应动作。Agnet 平台不是审批主体,不发起额外审批;它只校验 `approval_id`、审批主体、审批范围、TTL、`risk_level` 和策略是否匹配。
密钥处理边界如下:
@@ -89,6 +89,13 @@ Manager 可以有本地 user cache,但 canonical user identity 应来自登录
- OpenBao 不应作为普通公网路由开放;如果 Manager 已经提供客户端验证、资源绑定、审批和 `secret_ref` 管理接口,客户端不需要直连 OpenBao。
- OpenBao 访问应限制在容器网络、VM loopback、AKS 内网、Workload Identity 或其它受保护服务间通道。
OpenBao 不暴露公网的检查口径:
- 公网 DNS、Nginx `server_name`、Ingress、LoadBalancer 和安全组规则不得直接指向 OpenBao 服务端口。
- 外部客户端只能通过 Manager 的认证、资源绑定、审批和 `secret_ref` 管理接口间接操作密钥引用。
- Manager、Agnet 平台和子 Agnet 访问 OpenBao 时必须走内网地址、loopback、容器网络、AKS private endpoint 或 Workload Identity 绑定。
- 健康检查和联调报告只能证明内网访问可用;不得把公网可访问的 OpenBao health endpoint 作为验收口径。
短期凭证注入必须满足:
- 有客户端审批记录。
@@ -274,7 +274,7 @@ POST /api/agnet/deployments
| `intent_id` | string | 是 | Manager 侧意图 ID,用于幂等、审计和追踪。 |
| `template_hint` | string | 是 | Agnet 平台选择编排模板的提示,如 `manager-resource-binding`。 |
| `objective` | string | 是 | 本次部署目标,应是自然语言但不能含密钥。 |
| `risk_level` | enum | 是 | `low` / `medium` / `high`。高风险应触发审批或只读模式。 |
| `risk_level` | enum | 是 | `low` / `medium` / `high`。高风险必须携带客户端审批证据;缺失或不匹配时只能只读或拒绝执行。 |
| `budget.max_tokens` | int | 是 | 当前策略上限建议不超过 `500000`。 |
| `budget.max_cost_usd` | number | 是 | 当前策略上限建议不超过 `200`。 |
| `budget.max_duration_sec` | int | 是 | 当前策略上限建议不超过 `86400`。 |
@@ -899,10 +899,23 @@ Manager 发给 Agnet 平台前必须执行:
2. 凭据型资源只传 `secret_ref`,不传明文凭据。
3. `metadata`、`constraints`、`audit` 的 key 中不得出现 `password`、`token`、`secret`、`private_key`、`access_key`、`credential` 等敏感词。
4. `repo_url` 不得包含用户名、密码或访问 Token。
5. `permission_scope` 使用最小权限,生产写操作需审批记录或平台代理执行。
6. 高风险操作(生产部署、云资源修改、删除、扩容)应设置 `risk_level=high` 并由 Agnet 平台二次审批。
5. `permission_scope` 使用最小权限;生产写操作必须携带客户端审批记录,Agnet 平台不得自行补批。
6. 高风险操作(生产部署、云资源修改、删除、扩容)必须设置 `risk_level=high`,Agnet 平台执行前只校验客户端审批证据。
7. 所有日志/事件/审计返回给 Manager 前必须脱敏。
Agnet 平台不承担高危操作审批主体。审批只发生在客户端;Agnet 平台只能在执行前校验以下字段和策略是否一致:
| 校验项 | 要求 |
|---|---|
| `approval_id` | 必须存在于高危任务的 `constraints` 或 `audit`,并可追溯到客户端审批记录。 |
| 审批主体 | 审批用户必须与 `user_context.user_id`、`resource_grants[].user_id` 或授权代理主体一致。 |
| 审批范围 | 审批范围必须覆盖 `binding_scope`、`permission_scope`、目标环境、资源 ID 和操作类型。 |
| TTL / 时间窗口 | 审批记录必须未过期;若使用 `window` 或 TTL,当前执行时间必须落在允许范围内。 |
| `risk_level` | 高危资源写入、生产部署、云资源修改、删除和扩容必须为 `high`。 |
| 策略 | 平台 policy、OpenBao policy、Kubernetes/Workload Identity、网络策略和最小权限约束均必须允许本次动作。 |
任一校验不通过时,Agnet 平台应返回 `POLICY_REJECTED` 或 `FORBIDDEN_SCOPE`,不得发起额外批准流程。
### 8.1 字段级约束速查
| 对象/接口 | 必填最小集合 | 禁止内容 |
+130 -8
View File
@@ -151,11 +151,42 @@ type agnetDeploymentRecord struct {
DeploymentID string `json:"deployment_id"`
Status string `json:"status"`
Phase string `json:"phase"`
RuntimeState string `json:"runtime_state"`
FailureReason string `json:"failure_reason"`
AgentInstances []agnetAgentInstance `json:"agent_instances"`
ResourceGrantManifest agnetPermissionManifest `json:"permission_manifest"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Plan agnetOrchestrationPlan `json:"orchestration_plan"`
}
type agnetAgentInstance struct {
InstanceID string `json:"instance_id"`
Role string `json:"role"`
Phase string `json:"phase"`
RuntimeState string `json:"runtime_state"`
FailureReason string `json:"failure_reason"`
}
type agnetManifestGrant struct {
GrantID string `json:"grant_id"`
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
ResourceRef string `json:"resource_ref"`
AllowedActions []string `json:"allowed_actions"`
Constraints map[string]string `json:"constraints"`
SecretRef string `json:"secret_ref,omitempty"`
Status string `json:"status"`
}
type agnetPermissionManifest struct {
UserID string `json:"user_id"`
BindingScope string `json:"binding_scope"`
AgentRole string `json:"agent_role"`
TargetAgentRef string `json:"target_agent_ref"`
ResourceGrants []agnetManifestGrant `json:"resource_grants"`
}
type agnetEvent struct {
EventID string `json:"event_id"`
Event string `json:"event"`
@@ -518,6 +549,80 @@ func firstPlanBindingScope(plan agnetOrchestrationPlan) string {
return ""
}
func agnetGrantResourceRef(grant agnetResourceGrant) string {
for _, value := range []string{grant.Metadata["repo_url"], grant.Metadata["doc_ref"], grant.Metadata["resource_ref"], grant.BindingScope, grant.ResourceID} {
if strings.TrimSpace(value) != "" {
return value
}
}
return grant.GrantID
}
func buildAgnetPermissionManifest(plan agnetOrchestrationPlan) agnetPermissionManifest {
manifest := agnetPermissionManifest{
UserID: plan.UserContext.UserID,
BindingScope: firstPlanBindingScope(plan),
}
for _, agent := range plan.Agents {
for _, grant := range agent.ResourceGrants {
if strings.TrimSpace(grant.Status) != agnetGrantStatusActive {
continue
}
if manifest.AgentRole == "" {
manifest.AgentRole = grant.TargetRole
}
if manifest.TargetAgentRef == "" {
manifest.TargetAgentRef = grant.TargetAgentRef
}
manifest.ResourceGrants = append(manifest.ResourceGrants, agnetManifestGrant{
GrantID: grant.GrantID,
ResourceID: grant.ResourceID,
ResourceType: grant.ResourceType,
ResourceRef: agnetGrantResourceRef(grant),
AllowedActions: append([]string{}, grant.PermissionScope...),
Constraints: grant.Constraints,
SecretRef: grant.SecretRef,
Status: grant.Status,
})
}
}
if manifest.ResourceGrants == nil {
manifest.ResourceGrants = []agnetManifestGrant{}
}
return manifest
}
func buildAgnetAgentInstances(plan agnetOrchestrationPlan, phase string, runtimeState string) []agnetAgentInstance {
instances := make([]agnetAgentInstance, 0)
if len(plan.AgentRuntime.Agents) > 0 {
for _, runtimeAgent := range plan.AgentRuntime.Agents {
count := runtimeAgent.InstanceCount
if count <= 0 {
count = 1
}
for i := 0; i < count; i++ {
instances = append(instances, agnetAgentInstance{
InstanceID: "agi_" + common.GetUUID()[:12],
Role: runtimeAgent.Role,
Phase: phase,
RuntimeState: runtimeState,
})
}
}
}
if len(instances) == 0 {
for _, agent := range plan.Agents {
instances = append(instances, agnetAgentInstance{
InstanceID: "agi_" + common.GetUUID()[:12],
Role: agent.RoleTemplate,
Phase: phase,
RuntimeState: runtimeState,
})
}
}
return instances
}
func planHasBindingScope(plan agnetOrchestrationPlan, bindingScope string) bool {
bindingScope = strings.TrimSpace(bindingScope)
if bindingScope == "" {
@@ -569,6 +674,10 @@ func AgnetCreateDeployment(c *gin.Context) {
DeploymentID: deploymentID,
Status: "accepted",
Phase: "pending",
RuntimeState: "queued",
FailureReason: "",
AgentInstances: buildAgnetAgentInstances(plan, "pending", "queued"),
ResourceGrantManifest: buildAgnetPermissionManifest(plan),
CreatedAt: now,
UpdatedAt: now,
Plan: plan,
@@ -592,14 +701,12 @@ func AgnetCreateDeployment(c *gin.Context) {
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"status": "accepted",
"agent_instances": []gin.H{
{
"instance_id": "agi_" + common.GetUUID()[:12],
"role": plan.Agents[0].RoleTemplate,
"phase": "pending",
},
},
"status": record.Status,
"phase": record.Phase,
"runtime_state": record.RuntimeState,
"failure_reason": record.FailureReason,
"agent_instances": record.AgentInstances,
"permission_manifest": record.ResourceGrantManifest,
})
}
@@ -658,6 +765,13 @@ func AgnetStopDeployment(c *gin.Context) {
}
record.Status = "stopped"
record.Phase = "stopped"
record.RuntimeState = "stopped"
record.FailureReason = ""
for i := range record.AgentInstances {
record.AgentInstances[i].Phase = "stopped"
record.AgentInstances[i].RuntimeState = "stopped"
record.AgentInstances[i].FailureReason = ""
}
record.UpdatedAt = agnetNow()
agnetDeployments[deploymentID] = record
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], agnetEvent{
@@ -676,6 +790,8 @@ func AgnetStopDeployment(c *gin.Context) {
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"status": "stopped",
"phase": record.Phase,
"runtime_state": record.RuntimeState,
})
}
@@ -718,6 +834,8 @@ func AgnetListDeploymentLogs(c *gin.Context) {
"level": "info",
"message": "deployment accepted by Manager control-plane placeholder",
"phase": record.Phase,
"runtime_state": record.RuntimeState,
"failure_reason": record.FailureReason,
"correlation_id": record.Plan.Metadata.CorrelationID,
"redacted": true,
})
@@ -729,6 +847,8 @@ func AgnetListDeploymentLogs(c *gin.Context) {
"level": "info",
"message": event.Event,
"phase": record.Phase,
"runtime_state": record.RuntimeState,
"failure_reason": record.FailureReason,
"correlation_id": event.CorrelationID,
"redacted": true,
})
@@ -764,6 +884,8 @@ func AgnetGetDeploymentMetrics(c *gin.Context) {
"step": strings.TrimSpace(c.DefaultQuery("step", "60s")),
"phase": record.Phase,
"status": record.Status,
"runtime_state": record.RuntimeState,
"failure_reason": record.FailureReason,
"resource_usage": gin.H{
"cpu_percent": 0,
"memory_bytes": 0,
@@ -166,6 +166,34 @@ func TestAgnetCreateDeploymentAcceptsP1ResourceGrantModel(t *testing.T) {
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)
require.Equal(t, "queued", stored.RuntimeState)
require.Empty(t, stored.FailureReason)
require.Len(t, stored.AgentInstances, 1)
require.Equal(t, "queued", stored.AgentInstances[0].RuntimeState)
require.Len(t, stored.ResourceGrantManifest.ResourceGrants, 2)
require.Equal(t, "user-p1", stored.ResourceGrantManifest.UserID)
require.Equal(t, "builder", stored.ResourceGrantManifest.AgentRole)
require.Equal(t, "agent-builder-1", stored.ResourceGrantManifest.TargetAgentRef)
require.Equal(t, "vault://secret/users/user-p1/bindings/project-main/resources/res-git-main", stored.ResourceGrantManifest.ResourceGrants[0].SecretRef)
}
func TestAgnetPermissionManifestExcludesInactiveResourceGrants(t *testing.T) {
resetAgnetControlPlaneState(t)
plan := baseAgnetResourceGrantPlan()
plan.Agents[0].ResourceGrants[1].Status = agnetGrantStatusRevoked
_, envelope := postAgnetCreateDeployment(t, plan)
require.True(t, envelope.Success)
agnetMu.RLock()
var stored agnetDeploymentRecord
for _, record := range agnetDeployments {
stored = record
}
agnetMu.RUnlock()
require.Len(t, stored.ResourceGrantManifest.ResourceGrants, 1)
require.Equal(t, "grant-git-builder", stored.ResourceGrantManifest.ResourceGrants[0].GrantID)
}
func TestAgnetCreateDeploymentRejectsPlaintextResourceGrantCredentialFields(t *testing.T) {
@@ -292,4 +320,6 @@ func TestAgnetDeploymentLogsAndMetricsExposeRedactedReadiness(t *testing.T) {
require.Equal(t, http.StatusOK, metricRecorder.Code)
require.Contains(t, metricRecorder.Body.String(), `"deployment_id":"`+deploymentID+`"`)
require.Contains(t, metricRecorder.Body.String(), `"platform_estimated":true`)
require.Contains(t, metricRecorder.Body.String(), `"runtime_state":"queued"`)
require.Contains(t, metricRecorder.Body.String(), `"failure_reason":""`)
}
+113
View File
@@ -113,6 +113,26 @@ type resourceGrantResponse struct {
UpdatedAt int64 `json:"updated_at"`
}
type resourceGrantManifestGrant struct {
GrantId int `json:"grant_id"`
ResourceId int `json:"resource_id"`
ResourceType string `json:"resource_type"`
ResourceRef string `json:"resource_ref"`
AllowedActions []any `json:"allowed_actions"`
PermissionScope map[string]any `json:"permission_scope"`
Constraints map[string]any `json:"constraints"`
SecretRef string `json:"secret_ref,omitempty"`
Status string `json:"status"`
}
type resourceGrantManifestResponse struct {
UserId int `json:"user_id"`
BindingScope string `json:"binding_scope"`
AgentRole string `json:"agent_role"`
TargetAgentRef string `json:"target_agent_ref"`
ResourceGrants []resourceGrantManifestGrant `json:"resource_grants"`
}
func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) {
p.TenantId = strings.TrimSpace(p.TenantId)
p.ProjectId = strings.TrimSpace(p.ProjectId)
@@ -314,6 +334,54 @@ func resourceGrantToResponse(grant model.ResourceGrant, resource *model.Resource
return resp
}
func resourceGrantToManifestGrant(grant model.ResourceGrant, resource model.ResourceBinding) resourceGrantManifestGrant {
permissionScope := unmarshalResourceJSON(grant.PermissionScope)
return resourceGrantManifestGrant{
GrantId: grant.Id,
ResourceId: grant.ResourceId,
ResourceType: resource.ResourceType,
ResourceRef: resourceManifestRef(resource),
AllowedActions: manifestAllowedActions(permissionScope),
PermissionScope: permissionScope,
Constraints: unmarshalResourceJSON(grant.Constraints),
SecretRef: resource.SecretRef,
Status: grant.Status,
}
}
func resourceManifestRef(resource model.ResourceBinding) string {
for _, value := range []string{resource.ExternalId, resource.BindingScope, resource.Name} {
if strings.TrimSpace(value) != "" {
return value
}
}
return fmt.Sprintf("resource:%d", resource.Id)
}
func manifestAllowedActions(permissionScope map[string]any) []any {
actions, ok := permissionScope["actions"]
if !ok {
return []any{}
}
switch v := actions.(type) {
case []any:
return v
case []string:
out := make([]any, 0, len(v))
for _, action := range v {
out = append(out, action)
}
return out
case string:
if strings.TrimSpace(v) == "" {
return []any{}
}
return []any{v}
default:
return []any{}
}
}
func ListResources(c *gin.Context) {
userId := c.GetInt("id")
query := model.DB.Where("user_id = ?", userId)
@@ -564,6 +632,51 @@ func ListResourceGrants(c *gin.Context) {
common.ApiSuccess(c, gin.H{"items": items})
}
func GenerateResourceGrantManifest(c *gin.Context) {
userId := c.GetInt("id")
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
agentId := strings.TrimSpace(c.Query("agnet_id"))
role := strings.TrimSpace(c.Query("role"))
query := model.DB.Where("user_id = ? AND status = ?", userId, "active")
if bindingScope != "" {
query = query.Where("binding_scope = ?", bindingScope)
}
if agentId != "" {
query = query.Where("agnet_id = ?", agentId)
}
if role != "" {
query = query.Where("role = ?", role)
}
var grants []model.ResourceGrant
if err := query.Order("id asc").Find(&grants).Error; err != nil {
common.ApiError(c, err)
return
}
items := make([]resourceGrantManifestGrant, 0, len(grants))
for _, grant := range grants {
var resource model.ResourceBinding
if err := model.DB.Where("id = ? AND user_id = ? AND status = ?", grant.ResourceId, userId, "active").First(&resource).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
common.ApiError(c, err)
return
}
items = append(items, resourceGrantToManifestGrant(grant, resource))
}
common.ApiSuccess(c, resourceGrantManifestResponse{
UserId: userId,
BindingScope: bindingScope,
AgentRole: role,
TargetAgentRef: agentId,
ResourceGrants: items,
})
}
func CreateResourceGrant(c *gin.Context) {
userId := c.GetInt("id")
var payload resourceGrantPayload
+55
View File
@@ -164,6 +164,61 @@ func TestCreateResourceGrantRejectsMismatchedBindingScope(t *testing.T) {
require.Contains(t, w.Body.String(), "resource binding_scope does not match grant binding_scope")
}
func TestGenerateResourceGrantManifestIncludesActiveGrantsOnly(t *testing.T) {
db := setupResourceControllerTestDB(t)
resource := model.ResourceBinding{
UserId: 7,
BindingScope: "repo-main",
Name: "Project repository",
ResourceType: "git",
Provider: "github",
ExternalId: "https://example.com/org/repo.git",
SecretRef: "vault://secret/resources/repo-main",
Metadata: `{"repo_url":"https://example.com/org/repo.git"}`,
PermissionScope: `{"actions":["read","write"]}`,
Status: "active",
}
require.NoError(t, db.Create(&resource).Error)
require.NoError(t, db.Create(&model.ResourceGrant{
UserId: 7,
BindingScope: "repo-main",
ResourceId: resource.Id,
Role: "backend",
AgnetId: "agnet-backend-1",
PermissionScope: `{"actions":["read"]}`,
Constraints: `{"paths":["heicode/controller/**"]}`,
Status: "active",
}).Error)
require.NoError(t, db.Create(&model.ResourceGrant{
UserId: 7,
BindingScope: "repo-main",
ResourceId: resource.Id,
Role: "backend",
AgnetId: "agnet-backend-1",
PermissionScope: `{"actions":["write"]}`,
Status: "revoked",
}).Error)
w := performResourceRequestWithRoute(
GenerateResourceGrantManifest,
7,
http.MethodGet,
"/manifest",
"/manifest?binding_scope=repo-main&role=backend&agnet_id=agnet-backend-1",
"",
)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"success":true`)
require.Contains(t, w.Body.String(), `"binding_scope":"repo-main"`)
require.Contains(t, w.Body.String(), `"agent_role":"backend"`)
require.Contains(t, w.Body.String(), `"target_agent_ref":"agnet-backend-1"`)
require.Contains(t, w.Body.String(), `"resource_type":"git"`)
require.Contains(t, w.Body.String(), `"allowed_actions":["read"]`)
require.Contains(t, w.Body.String(), `"secret_ref":"vault://secret/resources/repo-main"`)
require.NotContains(t, w.Body.String(), `"allowed_actions":["write"]`)
}
func TestUpsertResourceSecretWritesOpenBaoAndStoresOnlySecretRef(t *testing.T) {
db := setupResourceControllerTestDB(t)
resource := model.ResourceBinding{
+1
View File
@@ -198,6 +198,7 @@ func SetApiRouter(router *gin.Engine) {
resourceGrantRoute.Use(middleware.UserAuth())
{
resourceGrantRoute.GET("/", controller.ListResourceGrants)
resourceGrantRoute.GET("/manifest", controller.GenerateResourceGrantManifest)
resourceGrantRoute.POST("/", controller.CreateResourceGrant)
resourceGrantRoute.PUT("/:id", controller.UpdateResourceGrant)
resourceGrantRoute.DELETE("/:id", controller.DeleteResourceGrant)
+6
View File
@@ -45,6 +45,12 @@ export type AgnetAgentPlan = {
binding_scope?: string
target_role?: string
target_agent_ref?: string
permission_scope?: string[]
constraints?: Record<string, string>
metadata?: Record<string, string>
status?: 'pending' | 'active' | 'disabled' | 'revoked'
secret_ref?: string
audit?: Record<string, string>
}>
}
File diff suppressed because it is too large Load Diff
+173 -29
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useMemo, useState, type ComponentType, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
@@ -78,7 +78,7 @@ function classifyStatus(status: string): StatusKey {
function StatusBadge({ phase }: { phase: string }) {
const k = classifyStatus(phase)
const map: Record<StatusKey, { cls: string; icon: React.ReactNode }> = {
const map: Record<StatusKey, { cls: string; icon: ReactNode }> = {
running: {
cls: 'bg-[color-mix(in_oklch,var(--primary)_22%,transparent)] text-primary ring-primary/40',
icon: <PlayCircle className='h-3 w-3' />,
@@ -113,8 +113,8 @@ function StatusBadge({ phase }: { phase: string }) {
function PageSurface(props: {
title: string
subtitle?: string
toolbar?: React.ReactNode
children: React.ReactNode
toolbar?: ReactNode
children: ReactNode
}) {
return (
<section className='space-y-5 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5 backdrop-blur'>
@@ -174,7 +174,7 @@ function MetaPill({
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
icon: ComponentType<{ className?: string }>
label: string
value: string
}) {
@@ -193,6 +193,12 @@ function describeRiskLevel(dep: AgnetDeployment): {
label: string
tone: 'low' | 'mid' | 'high'
} {
const explicit = dep.orchestration_plan?.risk_level?.toLowerCase()
if (explicit === 'high') return { label: 'high', tone: 'high' }
if (explicit === 'medium' || explicit === 'mid')
return { label: 'medium', tone: 'mid' }
if (explicit === 'low') return { label: 'low', tone: 'low' }
const objective = (dep.orchestration_plan?.objective || '').toLowerCase()
if (objective.includes('production') || objective.includes('critical')) {
return { label: 'high', tone: 'high' }
@@ -204,11 +210,20 @@ function describeRiskLevel(dep: AgnetDeployment): {
}
function describeBudget(dep: AgnetDeployment): string {
const plan = dep.orchestration_plan
const agents = plan?.agents?.length ?? 0
const budget = dep.orchestration_plan?.budget
if (!budget) {
const agents = dep.orchestration_plan?.agents?.length ?? 0
return `${agents} agents`
}
const parts = []
if (budget.max_cost_usd > 0) parts.push(`$${budget.max_cost_usd}`)
if (budget.max_tokens > 0) parts.push(`${budget.max_tokens} tokens`)
if (budget.max_duration_sec > 0)
parts.push(`${Math.round(budget.max_duration_sec / 60)}m`)
return parts.length > 0 ? parts.join(' / ') : '—'
}
function describeScope(dep: AgnetDeployment): string {
const firstGrant = dep.orchestration_plan?.agents?.flatMap(
(agent) => agent.resource_grants || []
@@ -220,6 +235,25 @@ function describeScope(dep: AgnetDeployment): string {
)
}
function collectResourceGrants(dep: AgnetDeployment): Record<string, unknown>[] {
return (
dep.orchestration_plan?.agents
?.flatMap((agent) => agent.resource_grants || [])
.map((grant) => grant as Record<string, unknown>) ?? []
)
}
function describeSecretRefs(dep: AgnetDeployment): string {
const grants = collectResourceGrants(dep)
const refs = grants.filter((grant) => {
const secretRef = grant.secret_ref
return typeof secretRef === 'string' && secretRef.trim() !== ''
})
if (refs.length > 0) return `${refs.length}/${grants.length || refs.length}`
return grants.length > 0 ? '0' : '—'
}
function formatRelativeTime(value: string | undefined): string {
if (!value) return '—'
const date = new Date(value)
@@ -235,6 +269,83 @@ function formatRelativeTime(value: string | undefined): string {
return `${day}d ago`
}
function RunDetailPanel({ dep }: { dep: AgnetDeployment }) {
const { t } = useTranslation()
const phase = dep.phase || dep.status
const risk = describeRiskLevel(dep)
const grants = collectResourceGrants(dep)
return (
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_52%,transparent)] p-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0'>
<p className='text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground'>
{t('Run detail')}
</p>
<p className='mt-1 truncate font-mono text-sm text-foreground'>
{dep.deployment_id}
</p>
<p className='mt-1 line-clamp-2 text-sm text-muted-foreground'>
{dep.orchestration_plan?.objective ||
dep.orchestration_plan?.template_hint ||
t('No objective')}
</p>
</div>
<StatusBadge phase={phase} />
</div>
<div className='mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-5'>
<MetaPill icon={Activity} label={t('status')} value={phase || '—'} />
<MetaPill icon={Tag} label={t('risk')} value={risk.label} />
<MetaPill icon={Coins} label={t('budget')} value={describeBudget(dep)} />
<MetaPill
icon={Building2}
label={t('scope')}
value={describeScope(dep)}
/>
<MetaPill
icon={ShieldCheck}
label='secret_ref'
value={describeSecretRefs(dep)}
/>
</div>
<div className='mt-4 grid gap-3 md:grid-cols-3'>
<div className='rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-background/45 p-3'>
<p className='flex items-center gap-2 text-xs font-semibold text-foreground'>
<FileSearch className='h-3.5 w-3.5 text-primary' />
{t('Permission manifest')}
</p>
<p className='mt-2 text-xs leading-relaxed text-muted-foreground'>
{t('Run manifest hint')}
</p>
<p className='mt-2 font-mono text-[11px] text-muted-foreground'>
{grants.length} {t('resource grants')}
</p>
</div>
<div className='rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-background/45 p-3'>
<p className='flex items-center gap-2 text-xs font-semibold text-foreground'>
<GitCommit className='h-3.5 w-3.5 text-primary' />
{t('Events usage')}
</p>
<p className='mt-2 text-xs leading-relaxed text-muted-foreground'>
{t('Run events hint')}
</p>
</div>
<div className='rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-background/45 p-3'>
<p className='flex items-center gap-2 text-xs font-semibold text-foreground'>
<ShieldCheck className='h-3.5 w-3.5 text-primary' />
{t('Audit usage')}
</p>
<p className='mt-2 text-xs leading-relaxed text-muted-foreground'>
{t('Run audit hint')}
</p>
</div>
</div>
</div>
)
}
// =============================================================================
// Deployments page
// =============================================================================
@@ -244,6 +355,7 @@ export function AgnetDeploymentsPage() {
const [filter, setFilter] = useState<'all' | StatusKey>('all')
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [selectedRunId, setSelectedRunId] = useState<string | undefined>()
const { data = [], isLoading } = useQuery({
queryKey: ['agnet', 'deployments'],
@@ -272,12 +384,15 @@ export function AgnetDeploymentsPage() {
})
}, [data, filter, keyword])
const selectedRun =
filtered.find((dep) => dep.deployment_id === selectedRunId) ?? filtered[0]
return (
<>
<PageSurface
title={t('Deployments')}
title={t('Work / Runs')}
subtitle={t(
'Card-based view of every Agnet orchestration run with risk, budget, executor and live status.'
'Track every Agnet work run by status, risk, budget, scope and secret_ref coverage.'
)}
toolbar={
<>
@@ -288,14 +403,14 @@ export function AgnetDeploymentsPage() {
onClick={() => setCreateOpen(true)}
>
<Plus className='h-3.5 w-3.5' />
{t('New deployment')}
{t('New run')}
</Button>
<div className='relative'>
<Search className='pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground' />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={t('Find deployment / scope / objective')}
placeholder={t('Find run / scope / objective')}
className='h-9 w-64 rounded-xl pl-8 text-xs'
/>
</div>
@@ -322,10 +437,12 @@ export function AgnetDeploymentsPage() {
<LoadingGrid rows={4} height='h-32' />
) : filtered.length === 0 ? (
<EmptySurface
title={t('No deployments match the current filter')}
title={t('No runs match the current filter')}
hint={t('Adjust filters or trigger a new orchestration plan.')}
/>
) : (
<div className='space-y-4'>
{selectedRun && <RunDetailPanel dep={selectedRun} />}
<div className='grid gap-3 sm:grid-cols-2'>
{filtered.map((dep) => {
const risk = describeRiskLevel(dep)
@@ -334,10 +451,26 @@ export function AgnetDeploymentsPage() {
dep.orchestration_plan?.objective ||
dep.orchestration_plan?.template_hint ||
t('No objective')
const selected = dep.deployment_id === selectedRun?.deployment_id
return (
<article
key={dep.deployment_id}
className='group flex flex-col gap-3 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4 transition hover:border-primary/45'
tabIndex={0}
role='button'
aria-pressed={selected}
onClick={() => setSelectedRunId(dep.deployment_id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setSelectedRunId(dep.deployment_id)
}
}}
className={cn(
'group flex cursor-pointer flex-col gap-3 rounded-2xl border bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4 transition hover:border-primary/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected
? 'border-primary/55'
: 'border-[color-mix(in_oklch,var(--primary)_18%,var(--border))]'
)}
>
<header className='flex items-start justify-between gap-2'>
<div className='min-w-0'>
@@ -355,7 +488,11 @@ export function AgnetDeploymentsPage() {
</header>
<div className='flex flex-wrap gap-1.5'>
<MetaPill icon={Tag} label={t('risk')} value={risk.label} />
<MetaPill
icon={Tag}
label={t('risk')}
value={risk.label}
/>
<MetaPill
icon={Coins}
label={t('budget')}
@@ -366,6 +503,11 @@ export function AgnetDeploymentsPage() {
label={t('scope')}
value={describeScope(dep)}
/>
<MetaPill
icon={ShieldCheck}
label='secret_ref'
value={describeSecretRefs(dep)}
/>
</div>
<footer className='mt-auto flex items-center justify-between border-t border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pt-3'>
@@ -388,6 +530,7 @@ export function AgnetDeploymentsPage() {
)
})}
</div>
</div>
)}
</PageSurface>
<CreateAgnetDeploymentSheet
@@ -684,7 +827,8 @@ export function AgnetAuditPage() {
}
// =============================================================================
// Git sources — 绑定代码/SK 仓库与云上权限 → 部署 → 展示各部署的快照锚点(SK 正文仍以 Git 为准)
// Resources — keep Git-source API wiring while presenting it as the first
// resource-binding slice for Work/Runs.
// =============================================================================
export function AgnetSKSourcesPage() {
@@ -758,8 +902,8 @@ export function AgnetSKSourcesPage() {
return (
<PageSurface
title={t('Git sources')}
subtitle={t('Git sources subtitle')}
title={t('Resources')}
subtitle={t('Resources subtitle')}
toolbar={
<Select
value={effectiveDeployment ?? ''}
@@ -785,10 +929,10 @@ export function AgnetSKSourcesPage() {
<div className='flex items-center justify-between gap-3'>
<div>
<p className='text-sm font-medium text-foreground'>
{t('Bind Git source')}
{t('Bind resource source')}
</p>
<p className='mt-1 text-xs text-muted-foreground'>
{t('Bind Git source description')}
{t('Bind resource source description')}
</p>
</div>
<GitBranch className='h-5 w-5 text-primary' />
@@ -933,7 +1077,7 @@ export function AgnetSKSourcesPage() {
<div className='rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_50%,transparent)] p-4'>
<p className='text-sm font-medium text-foreground'>
{t('Bound Git sources')}
{t('Bound resource sources')}
</p>
{gitSourcesQuery.isLoading ? (
<div className='mt-3 space-y-2'>
@@ -942,7 +1086,7 @@ export function AgnetSKSourcesPage() {
</div>
) : gitSources.length === 0 ? (
<p className='mt-3 text-xs text-muted-foreground'>
{t('No Git sources bound yet')}
{t('No resource sources bound yet')}
</p>
) : (
<ul className='mt-3 space-y-2'>
@@ -986,20 +1130,20 @@ export function AgnetSKSourcesPage() {
</div>
<div className='rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_50%,transparent)] p-4 text-sm leading-relaxed text-muted-foreground'>
<p className='font-medium text-foreground'>{t('Git binding')}</p>
<p className='mt-2'>{t('Git sources binding explainer')}</p>
<p className='font-medium text-foreground'>{t('Resource binding')}</p>
<p className='mt-2'>{t('Resources binding explainer')}</p>
</div>
<div className='mt-4 rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_50%,transparent)] p-4 text-sm leading-relaxed text-muted-foreground'>
<p className='font-medium text-foreground'>
{t('Git sources workflow title')}
{t('Resources workflow title')}
</p>
<ol className='mt-3 list-decimal space-y-2 ps-5 marker:text-muted-foreground'>
<li>{t('Git sources workflow step 1')}</li>
<li>{t('Git sources workflow step 2')}</li>
<li>{t('Git sources workflow step 3')}</li>
<li>{t('Git sources workflow step 4')}</li>
<li>{t('Git sources workflow step 5')}</li>
<li>{t('Resources workflow step 1')}</li>
<li>{t('Resources workflow step 2')}</li>
<li>{t('Resources workflow step 3')}</li>
<li>{t('Resources workflow step 4')}</li>
<li>{t('Resources workflow step 5')}</li>
</ol>
</div>
+48 -1
View File
@@ -3870,6 +3870,53 @@
"Your Turnstile site key": "Your Turnstile site key",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
"Zoom": "Zoom",
"Resource permission scope is required": "Resource permission scope is required",
"Secret ref is required for credential resources": "Secret ref is required for credential resources",
"New work": "New work",
"Create a deployment from an idea, bounded resources, role cards, and a permission manifest.": "Create a deployment from an idea, bounded resources, role cards, and a permission manifest.",
"Idea": "Idea",
"Roles": "Roles",
"Review": "Review",
"Idea and objective": "Idea and objective",
"Start with the work outcome. Technical fields stay in the generated deployment plan.": "Start with the work outcome. Technical fields stay in the generated deployment plan.",
"roles": "roles",
"Describe what this work should achieve.": "Describe what this work should achieve.",
"Resource scope": "Resource scope",
"Scope is metadata and secret_ref only. Plaintext credentials do not belong in this form.": "Scope is metadata and secret_ref only. Plaintext credentials do not belong in this form.",
"Role cards": "Role cards",
"Each card maps one child Agnet role to a runtime model and one bounded resource grant.": "Each card maps one child Agnet role to a runtime model and one bounded resource grant.",
"Resource grant": "Resource grant",
"Permissions and manifest": "Permissions and manifest",
"Review the runtime model and resource grants before submitting.": "Review the runtime model and resource grants before submitting.",
"I confirm this manifest grants only the listed resources and contains no plaintext credentials.": "I confirm this manifest grants only the listed resources and contains no plaintext credentials.",
"Run detail": "Run detail",
"status": "status",
"Permission manifest": "Permission manifest",
"Run manifest hint": "Use the manifest as the structured contract: resource grants, scope, budget, runtime policy, and secret_ref references only.",
"resource grants": "resource grants",
"Events usage": "Events usage",
"Run events hint": "Open events when a run changes phase, a policy blocks execution, or a snapshot anchor needs correlation.",
"Audit usage": "Audit usage",
"Run audit hint": "Use audit to answer who approved or changed the run, which scope was used, and when a Resource Grant should be revoked.",
"Work / Runs": "Work / Runs",
"Track every Agnet work run by status, risk, budget, scope and secret_ref coverage.": "Track every Agnet work run by status, risk, budget, scope and secret_ref coverage.",
"New run": "New run",
"Find run / scope / objective": "Find run / scope / objective",
"No runs match the current filter": "No runs match the current filter",
"Resources": "Resources",
"Resources subtitle": "Bind resource sources, then inspect the snapshot anchors each Work/Run resolved for manifest and audit replay.",
"Bind resource source": "Bind resource source",
"Bind resource source description": "Register the Git-backed project, SK, or combined resource slice that Work/Runs can turn into scoped Resource Grants.",
"Bound resource sources": "Bound resource sources",
"No resource sources bound yet": "No resource sources bound yet.",
"Resource binding": "Resource binding",
"Resources binding explainer": "Resources are the user-visible bindings behind runs: code repos, SK repos, project documents, cloud accounts, and cloud resources. This page still uses the Git-source API slice, but the operating model is Resource Binding plus Resource Grant: metadata, scope, status, and secret_ref only.",
"Resources workflow title": "Resource-to-run flow",
"Resources workflow step 1": "Bind project code, SK, document, or cloud-resource metadata without exposing raw credentials.",
"Resources workflow step 2": "Keep secret material behind secret_ref; Manager surfaces references and status only.",
"Resources workflow step 3": "Allocate scope, allowed paths/actions, runtime policy, and budget to the run manifest.",
"Resources workflow step 4": "Start a Work/Run; Agnet resolves immutable anchors and enforces the effective grants.",
"Resources workflow step 5": "Use snapshots, events, and audit together to replay which resource context actually ran."
}
}
+48 -1
View File
@@ -3870,6 +3870,53 @@
"Your Turnstile site key": "您的 Turnstile 站点密钥",
"Zhipu": "智谱",
"Zhipu V4": "智谱 V4",
"Zoom": "缩放"
"Zoom": "缩放",
"Resource permission scope is required": "资源权限范围为必填项",
"Secret ref is required for credential resources": "凭据型资源必须填写 secret_ref",
"New work": "新工作",
"Create a deployment from an idea, bounded resources, role cards, and a permission manifest.": "从想法、受限资源、角色卡和权限清单创建一次部署。",
"Idea": "想法",
"Roles": "角色",
"Review": "确认",
"Idea and objective": "想法与目标",
"Start with the work outcome. Technical fields stay in the generated deployment plan.": "先描述工作结果,技术字段会进入生成的部署计划。",
"roles": "个角色",
"Describe what this work should achieve.": "描述这次工作要达成什么。",
"Resource scope": "资源范围",
"Scope is metadata and secret_ref only. Plaintext credentials do not belong in this form.": "这里只填写 metadata 和 secret_ref,不能填写明文凭据。",
"Role cards": "角色卡",
"Each card maps one child Agnet role to a runtime model and one bounded resource grant.": "每张卡把一个子 Agnet 角色映射到运行模型和一个受限资源授权。",
"Resource grant": "资源授权",
"Permissions and manifest": "权限与清单",
"Review the runtime model and resource grants before submitting.": "提交前确认运行模型与资源授权。",
"I confirm this manifest grants only the listed resources and contains no plaintext credentials.": "我确认该清单仅授权列出的资源,且不包含明文凭据。",
"Run detail": "运行详情",
"status": "状态",
"Permission manifest": "权限 manifest",
"Run manifest hint": "把 manifest 作为结构化契约查看:资源授权、范围、预算、运行策略和 secret_ref 引用。",
"resource grants": "资源授权",
"Events usage": "事件用法",
"Run events hint": "运行变更阶段、策略阻断执行或需要关联快照锚点时查看事件。",
"Audit usage": "审计用法",
"Run audit hint": "用审计回答谁批准或修改了运行、使用了哪个范围,以及何时应撤销 Resource Grant。",
"Work / Runs": "工作 / 运行",
"Track every Agnet work run by status, risk, budget, scope and secret_ref coverage.": "按状态、风险、预算、作用域和 secret_ref 覆盖情况跟踪每次 Agnet 工作运行。",
"New run": "新运行",
"Find run / scope / objective": "查找运行 / 作用域 / 目标",
"No runs match the current filter": "没有匹配当前筛选条件的运行",
"Resources": "资源",
"Resources subtitle": "绑定资源来源,并查看每次 Work/Run 为 manifest 与审计回放解析出的快照锚点。",
"Bind resource source": "绑定资源来源",
"Bind resource source description": "登记 Work/Runs 可转成带作用域 Resource Grant 的 Git 项目、SK 或二合一资源切片。",
"Bound resource sources": "已绑定资源来源",
"No resource sources bound yet": "还没有绑定资源来源。",
"Resource binding": "资源绑定",
"Resources binding explainer": "Resources 是运行背后的用户可见绑定:代码仓库、SK 仓库、项目文档、云账号和云资源。本页仍复用 Git source API 切片,但运行模型是 Resource Binding 加 Resource Grant:只包含 metadata、scope、status 和 secret_ref。",
"Resources workflow title": "资源到运行流程",
"Resources workflow step 1": "绑定项目代码、SK、文档或云资源 metadata,不暴露原始凭据。",
"Resources workflow step 2": "密钥材料留在 secret_ref 后面;Manager 只展示引用和状态。",
"Resources workflow step 3": "为运行 manifest 分配范围、允许路径/动作、运行策略和预算。",
"Resources workflow step 4": "启动 Work/Run;Agnet 解析不可变锚点并执行有效授权。",
"Resources workflow step 5": "结合快照、事件和审计回放实际运行的资源上下文。"
}
}