diff --git a/docs/heicode-runtime-auth-newapi-secret-design.md b/docs/heicode-runtime-auth-newapi-secret-design.md index 3a6d152..b31ea22 100644 --- a/docs/heicode-runtime-auth-newapi-secret-design.md +++ b/docs/heicode-runtime-auth-newapi-secret-design.md @@ -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 作为验收口径。 + 短期凭证注入必须满足: - 有客户端审批记录。 diff --git a/docs/integration/agnet-platform-request-contract.md b/docs/integration/agnet-platform-request-contract.md index ae33e8b..e13a7dc 100644 --- a/docs/integration/agnet-platform-request-contract.md +++ b/docs/integration/agnet-platform-request-contract.md @@ -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 字段级约束速查 | 对象/接口 | 必填最小集合 | 禁止内容 | diff --git a/heicode/controller/agnet_control_plane.go b/heicode/controller/agnet_control_plane.go index 4482e1a..0ff5289 100644 --- a/heicode/controller/agnet_control_plane.go +++ b/heicode/controller/agnet_control_plane.go @@ -148,12 +148,43 @@ type agnetDeploymentRequest struct { } type agnetDeploymentRecord struct { - DeploymentID string `json:"deployment_id"` - Status string `json:"status"` - Phase string `json:"phase"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Plan agnetOrchestrationPlan `json:"orchestration_plan"` + 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 { @@ -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 == "" { @@ -566,12 +671,16 @@ func AgnetCreateDeployment(c *gin.Context) { now := agnetNow() deploymentID := "dep_" + common.GetUUID()[:12] record := agnetDeploymentRecord{ - DeploymentID: deploymentID, - Status: "accepted", - Phase: "pending", - CreatedAt: now, - UpdatedAt: now, - Plan: plan, + DeploymentID: deploymentID, + Status: "accepted", + Phase: "pending", + RuntimeState: "queued", + FailureReason: "", + AgentInstances: buildAgnetAgentInstances(plan, "pending", "queued"), + ResourceGrantManifest: buildAgnetPermissionManifest(plan), + CreatedAt: now, + UpdatedAt: now, + Plan: plan, } event := agnetEvent{ EventID: "evt_" + common.GetUUID()[:12], @@ -591,15 +700,13 @@ func AgnetCreateDeployment(c *gin.Context) { agnetMu.Unlock() 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", - }, - }, + "deployment_id": deploymentID, + "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, }) @@ -759,11 +879,13 @@ func AgnetGetDeploymentMetrics(c *gin.Context) { } common.ApiSuccess(c, gin.H{ - "deployment_id": deploymentID, - "window": strings.TrimSpace(c.DefaultQuery("window", "15m")), - "step": strings.TrimSpace(c.DefaultQuery("step", "60s")), - "phase": record.Phase, - "status": record.Status, + "deployment_id": deploymentID, + "window": strings.TrimSpace(c.DefaultQuery("window", "15m")), + "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, diff --git a/heicode/controller/agnet_control_plane_test.go b/heicode/controller/agnet_control_plane_test.go index fa1ab51..0d35c15 100644 --- a/heicode/controller/agnet_control_plane_test.go +++ b/heicode/controller/agnet_control_plane_test.go @@ -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":""`) } diff --git a/heicode/controller/resource.go b/heicode/controller/resource.go index 4b1e35e..aeac6c1 100644 --- a/heicode/controller/resource.go +++ b/heicode/controller/resource.go @@ -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 diff --git a/heicode/controller/resource_test.go b/heicode/controller/resource_test.go index a03e835..21b9b61 100644 --- a/heicode/controller/resource_test.go +++ b/heicode/controller/resource_test.go @@ -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{ diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index d9df80a..00d1010 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -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) diff --git a/heicode/web/default/src/features/agnet-console/api.ts b/heicode/web/default/src/features/agnet-console/api.ts index 423d65a..91dd670 100644 --- a/heicode/web/default/src/features/agnet-console/api.ts +++ b/heicode/web/default/src/features/agnet-console/api.ts @@ -45,6 +45,12 @@ export type AgnetAgentPlan = { binding_scope?: string target_role?: string target_agent_ref?: string + permission_scope?: string[] + constraints?: Record + metadata?: Record + status?: 'pending' | 'active' | 'disabled' | 'revoked' + secret_ref?: string + audit?: Record }> } diff --git a/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx b/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx index 1180ac1..b51ee41 100644 --- a/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx +++ b/heicode/web/default/src/features/agnet-console/create-agnet-deployment-sheet.tsx @@ -1,12 +1,32 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState, type ComponentType } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { Loader2, Plus, Trash2 } from 'lucide-react' +import { + Bot, + CheckCircle2, + FileText, + GitBranch, + KeyRound, + Layers3, + Loader2, + Plus, + ShieldCheck, + Sparkles, + Trash2, +} from 'lucide-react' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Sheet, SheetContent, @@ -15,14 +35,9 @@ import { SheetHeader, SheetTitle, } from '@/components/ui/sheet' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Textarea } from '@/components/ui/textarea' +import { cn } from '@/lib/utils' import { useAuthStore } from '@/stores/auth-store' import { createAgnetDeployment, @@ -31,6 +46,13 @@ import { type AgnetSKSource, } from './api' +type ResourceType = + | 'git' + | 'sk' + | 'project_doc' + | 'cloud_account' + | 'cloud_resource' + type AgentFormRow = { role_template: string goal: string @@ -42,8 +64,119 @@ type AgentFormRow = { policy_ref: string deny_skill_ids: string inherit_defaults: boolean + resource_type: ResourceType + resource_id: string + binding_scope: string + permission_scope: string + constraint_paths: string + constraint_ref: string + metadata_provider: string + metadata_ref: string + secret_ref: string } +type TemplatePreset = { + id: string + label: string + objective: string + risk: 'low' | 'medium' | 'high' + roles: Array> +} + +const RESOURCE_TYPES: ResourceType[] = [ + 'git', + 'sk', + 'project_doc', + 'cloud_account', + 'cloud_resource', +] + +const CREDENTIAL_RESOURCE_TYPES = new Set([ + 'git', + 'sk', + 'cloud_account', + 'cloud_resource', +]) + +const TEMPLATE_PRESETS: TemplatePreset[] = [ + { + id: 'new_work_mvp', + label: 'MVP delivery', + objective: 'Turn a product idea into a scoped MVP delivery run.', + risk: 'medium', + roles: [ + { + role_template: 'product', + goal: 'Clarify the objective and produce the work brief.', + default_model_id: 'agnet-model-product', + }, + { + role_template: 'frontend', + goal: 'Build the first usable UI path from the work brief.', + default_model_id: 'agnet-model-builder', + }, + { + role_template: 'reviewer', + goal: 'Review the plan, permissions, and delivery evidence.', + default_model_id: 'agnet-model-reviewer', + }, + ], + }, + { + id: 'new_work_fix', + label: 'Fix or improve', + objective: 'Investigate a focused issue and ship the smallest safe fix.', + risk: 'low', + roles: [ + { + role_template: 'debugger', + goal: 'Find the root cause and define the minimum repair.', + default_model_id: 'agnet-model-debugger', + }, + { + role_template: 'executor', + goal: 'Implement and verify the scoped change.', + default_model_id: 'agnet-model-builder', + }, + ], + }, + { + id: 'new_work_release', + label: 'Release readiness', + objective: 'Prepare a release candidate with verification and audit trail.', + risk: 'high', + roles: [ + { + role_template: 'executor', + goal: 'Prepare the release branch and required artifacts.', + default_model_id: 'agnet-model-builder', + }, + { + role_template: 'ops', + goal: 'Validate deployment boundaries and rollback readiness.', + default_model_id: 'agnet-model-ops', + }, + { + role_template: 'reviewer', + goal: 'Confirm evidence, permissions, and known risks.', + default_model_id: 'agnet-model-reviewer', + }, + ], + }, +] + +const DEFAULT_SK_JSON = `[ + { + "type": "git", + "repo_ref": { + "connection_id": "", + "repo_url": "", + "ref": "main", + "paths": ["policy/sk.md"] + } + } +]` + function splitComma(s: string): string[] { return s .split(',') @@ -51,7 +184,50 @@ function splitComma(s: string): string[] { .filter(Boolean) } -function buildAgent(row: AgentFormRow): AgnetAgentPlan { +function sanitizeRef(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function emptyAgent( + role?: Pick +): AgentFormRow { + const roleRef = role?.role_template || 'builder' + return { + role_template: role?.role_template || '', + goal: role?.goal || '', + default_model_id: role?.default_model_id || '', + sk_sources_json: '[]', + profile_id: 'default-runtime', + cloud_principals: '', + network_policy_ref: '', + policy_ref: 'default-sk-policy', + deny_skill_ids: '', + inherit_defaults: true, + resource_type: 'project_doc', + resource_id: `res-${sanitizeRef(roleRef) || 'work'}-doc`, + binding_scope: 'docs/heicode.md', + permission_scope: 'doc:read', + constraint_paths: 'docs/**', + constraint_ref: 'main', + metadata_provider: 'manager', + metadata_ref: 'docs/heicode.md', + secret_ref: '', + } +} + +function buildAgent( + row: AgentFormRow, + index: number, + context: { + userId: string + resourceScopeRef: string + correlationId: string + } +): AgnetAgentPlan { let sk_sources: AgnetSKSource[] | undefined const raw = row.sk_sources_json.trim() if (raw) { @@ -62,6 +238,7 @@ function buildAgent(row: AgentFormRow): AgnetAgentPlan { sk_sources = parsed as AgnetSKSource[] } + const role = row.role_template.trim() const profileId = row.profile_id.trim() const net = row.network_policy_ref.trim() const clouds = splitComma(row.cloud_principals) @@ -70,8 +247,22 @@ function buildAgent(row: AgentFormRow): AgnetAgentPlan { throw new Error('profile_required_for_runtime') } + const permissionScope = splitComma(row.permission_scope) + if (permissionScope.length === 0) { + throw new Error('resource_permission_required') + } + if ( + CREDENTIAL_RESOURCE_TYPES.has(row.resource_type) && + row.secret_ref.trim() === '' + ) { + throw new Error('resource_secret_ref_required') + } + const deny = splitComma(row.deny_skill_ids) const pref = row.policy_ref.trim() + const targetAgentRef = `agent-${sanitizeRef(role) || index + 1}-${index + 1}` + const paths = splitComma(row.constraint_paths) + const resourceId = row.resource_id.trim() const runtime = profileId || net || clouds.length > 0 @@ -92,39 +283,60 @@ function buildAgent(row: AgentFormRow): AgnetAgentPlan { : undefined return { - role_template: row.role_template.trim(), + role_template: role, goal: row.goal.trim(), default_model_id: row.default_model_id.trim() || undefined, sk_sources, runtime_execution: runtime, sk_access_policy: skPolicy, + resource_grants: [ + { + grant_id: `grant-${sanitizeRef(resourceId) || index + 1}-${index + 1}`, + resource_id: resourceId, + resource_type: row.resource_type, + user_id: context.userId, + binding_scope: row.binding_scope.trim() || context.resourceScopeRef, + target_role: role, + target_agent_ref: targetAgentRef, + permission_scope: permissionScope, + constraints: { + ref: row.constraint_ref.trim() || 'main', + allowed_paths: paths.join(','), + }, + metadata: { + provider: row.metadata_provider.trim() || 'manager', + resource_ref: row.metadata_ref.trim() || resourceId, + }, + status: 'active', + secret_ref: row.secret_ref.trim() || undefined, + audit: { + confirmed_by: context.userId, + correlation_id: context.correlationId, + }, + }, + ], } } -const DEFAULT_SK_JSON = `[ - { - "type": "git", - "repo_ref": { - "connection_id": "", - "repo_url": "", - "ref": "main", - "paths": ["policy/sk.md"] - } - } -]` - -const emptyAgent = (): AgentFormRow => ({ - role_template: '', - goal: '', - default_model_id: '', - sk_sources_json: '[]', - profile_id: '', - cloud_principals: '', - network_policy_ref: '', - policy_ref: '', - deny_skill_ids: '', - inherit_defaults: true, -}) +function SectionTitle({ + icon: Icon, + title, + hint, +}: { + icon: ComponentType<{ className?: string }> + title: string + hint?: string +}) { + return ( +
+ +
+

{title}

+ {hint ?

{hint}

: null} +
+
+ ) +} export function CreateAgnetDeploymentSheet({ open, @@ -137,10 +349,12 @@ export function CreateAgnetDeploymentSheet({ const queryClient = useQueryClient() const currentUser = useAuthStore((state) => state.auth.user) - const [templateHint, setTemplateHint] = useState('agile_min') - const [objective, setObjective] = useState('') + const defaultTemplate = TEMPLATE_PRESETS[0] + const [step, setStep] = useState('idea') + const [templateHint, setTemplateHint] = useState(defaultTemplate.id) + const [objective, setObjective] = useState(defaultTemplate.objective) const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high'>( - 'medium' + defaultTemplate.risk ) const [maxTokens, setMaxTokens] = useState(120_000) const [maxCost, setMaxCost] = useState(8) @@ -149,7 +363,12 @@ export function CreateAgnetDeploymentSheet({ const [resourceScopeRef, setResourceScopeRef] = useState('repo_default') const [correlationId, setCorrelationId] = useState('') const [allowedModels, setAllowedModels] = useState('') - const [agents, setAgents] = useState([emptyAgent()]) + const [permissionConfirmed, setPermissionConfirmed] = useState(false) + const [agents, setAgents] = useState( + defaultTemplate.roles.map((role) => emptyAgent(role)) + ) + + const userId = String(currentUser?.id || userScopeRef.trim() || 'user_local') useEffect(() => { if (open && !correlationId) { @@ -158,9 +377,10 @@ export function CreateAgnetDeploymentSheet({ }, [open, correlationId]) const resetForm = () => { - setTemplateHint('agile_min') - setObjective('') - setRiskLevel('medium') + setStep('idea') + setTemplateHint(defaultTemplate.id) + setObjective(defaultTemplate.objective) + setRiskLevel(defaultTemplate.risk) setMaxTokens(120_000) setMaxCost(8) setMaxDurationSec(3600) @@ -168,22 +388,167 @@ export function CreateAgnetDeploymentSheet({ setResourceScopeRef('repo_default') setCorrelationId(crypto.randomUUID()) setAllowedModels('') - setAgents([emptyAgent()]) + setPermissionConfirmed(false) + setAgents(defaultTemplate.roles.map((role) => emptyAgent(role))) } + const updateAgent = (index: number, patch: Partial) => { + setAgents((prev) => { + const next = [...prev] + next[index] = { ...next[index], ...patch } + return next + }) + } + + const applyTemplate = (id: string) => { + const template = TEMPLATE_PRESETS.find((item) => item.id === id) + if (!template) return + setTemplateHint(template.id) + setObjective(template.objective) + setRiskLevel(template.risk) + setPermissionConfirmed(false) + setAgents(template.roles.map((role) => emptyAgent(role))) + } + + const fillExampleSk = (index: number) => { + updateAgent(index, { sk_sources_json: DEFAULT_SK_JSON }) + } + + const previewPlan = useMemo(() => { + const allowed = splitComma(allowedModels) + const previewAgents = agents.map((agent, index) => { + const role = agent.role_template.trim() + const resourceId = agent.resource_id.trim() + const targetAgentRef = `agent-${sanitizeRef(role) || index + 1}-${ + index + 1 + }` + return { + role_template: role, + goal: agent.goal.trim(), + default_model_id: agent.default_model_id.trim() || undefined, + runtime_execution: { + profile_id: agent.profile_id.trim() || undefined, + cloud_principal_refs: splitComma(agent.cloud_principals), + network_policy_ref: agent.network_policy_ref.trim() || undefined, + }, + resource_grants: [ + { + grant_id: `grant-${sanitizeRef(resourceId) || index + 1}-${ + index + 1 + }`, + resource_id: resourceId, + resource_type: agent.resource_type, + user_id: userId, + binding_scope: + agent.binding_scope.trim() || resourceScopeRef.trim(), + target_role: role, + target_agent_ref: targetAgentRef, + permission_scope: splitComma(agent.permission_scope), + constraints: { + ref: agent.constraint_ref.trim() || 'main', + allowed_paths: splitComma(agent.constraint_paths).join(','), + }, + metadata: { + provider: agent.metadata_provider.trim() || 'manager', + resource_ref: agent.metadata_ref.trim() || resourceId, + }, + status: 'active', + secret_ref: agent.secret_ref.trim() || undefined, + }, + ], + } + }) + + return { + intent_id: '', + template_hint: templateHint.trim(), + objective: objective.trim(), + risk_level: riskLevel, + resource_scope_ref: resourceScopeRef.trim(), + agent_runtime: { + platform: 'agnet', + agents: previewAgents + .filter((agent) => agent.role_template && agent.default_model_id) + .map((agent) => ({ + role: agent.role_template, + model_ref: agent.default_model_id, + instance_count: 1, + })), + }, + permission_manifest: { + user_id: userId, + binding_scope: resourceScopeRef.trim(), + resource_grants: previewAgents.flatMap( + (agent) => agent.resource_grants + ), + }, + constraints: { + allowed_model_ids: allowed.length > 0 ? allowed : undefined, + }, + } + }, [ + allowedModels, + agents, + objective, + resourceScopeRef, + riskLevel, + templateHint, + userId, + ]) + + const canSubmit = useMemo(() => { + return ( + templateHint.trim() && + objective.trim() && + userScopeRef.trim() && + resourceScopeRef.trim() && + correlationId.trim() && + permissionConfirmed && + agents.every((agent) => { + const requiresSecret = CREDENTIAL_RESOURCE_TYPES.has( + agent.resource_type + ) + return ( + agent.role_template.trim() && + agent.goal.trim() && + agent.default_model_id.trim() && + agent.resource_id.trim() && + (agent.binding_scope.trim() || resourceScopeRef.trim()) && + splitComma(agent.permission_scope).length > 0 && + (!requiresSecret || agent.secret_ref.trim()) + ) + }) + ) + }, [ + agents, + correlationId, + objective, + permissionConfirmed, + resourceScopeRef, + templateHint, + userScopeRef, + ]) + const mutation = useMutation({ mutationFn: async () => { const intentId = crypto.randomUUID() const allowed = splitComma(allowedModels) let agentPlans: AgnetAgentPlan[] try { - agentPlans = agents.map((row) => buildAgent(row)) + agentPlans = agents.map((row, index) => + buildAgent(row, index, { + userId, + resourceScopeRef: resourceScopeRef.trim(), + correlationId: correlationId.trim(), + }) + ) } catch (e) { if (e instanceof SyntaxError) { throw new Error('invalid_sk_sources_json') } throw e } + const runtimeAgents = agentPlans .filter((agent) => agent.role_template && agent.default_model_id) .map((agent) => ({ @@ -203,7 +568,7 @@ export function CreateAgnetDeploymentSheet({ max_duration_sec: maxDurationSec, }, user_context: { - user_id: String(currentUser?.id || userScopeRef.trim()), + user_id: userId, email: currentUser?.email || undefined, role: String(currentUser?.role || 'user'), channel_id: currentUser?.group || undefined, @@ -258,377 +623,546 @@ export function CreateAgnetDeploymentSheet({ toast.error(t('Execution profile required when cloud or network set')) return } + if (code === 'resource_permission_required') { + toast.error(t('Resource permission scope is required')) + return + } + if (code === 'resource_secret_ref_required') { + toast.error(t('Secret ref is required for credential resources')) + return + } toast.error(err.message || t('Deployment request failed')) }, }) - const canSubmit = useMemo(() => { - return ( - templateHint.trim() && - objective.trim() && - userScopeRef.trim() && - resourceScopeRef.trim() && - correlationId.trim() && - agents.every((a) => a.role_template.trim() && a.goal.trim()) - ) - }, [ - templateHint, - objective, - userScopeRef, - resourceScopeRef, - correlationId, - agents, - ]) - - const fillExampleSk = (index: number) => { - setAgents((prev) => { - const next = [...prev] - next[index] = { ...next[index], sk_sources_json: DEFAULT_SK_JSON } - return next - }) - } - return ( - + - {t('Create Agnet deployment')} + {t('New work')} - {t('Create Agnet deployment description')} + {t( + 'Create a deployment from an idea, bounded resources, role cards, and a permission manifest.' + )} -
-
-

- {t('Deployment plan')} -

-
- - setTemplateHint(e.target.value)} - className='font-mono text-xs' - /> -
-
- -