按桌面客户端统一方案 v0.1 + agent_management Sub Mode Runtime 对接,强制全量统一,不留兼容。
命名统一(强制,无兼容):
- 全仓 agnet/Agnet/AGNET → agent/Agent/AGENT:后端 Go(路由 /api/agent/*、env AGENT_*、
结构体/函数、19 个文件改名)、前端(agent-console/agent-hub、/api/agent 调用、i18n)、
DB(表 agent_*、列 agent_id)、compose/.env、文档、脚本。
- DB 加幂等迁移 renameAgnetTablesToAgent():启动时 rename 老 agnet_* 表/列,保住生产数据。
统一方案核心(10 项):
- callback 统一 /api/agent/callbacks/runtime-events(路由/广播URL/函数名)。
- artifact 兜底判定改用 Runtime 权威信号 metadata.synthesized(§7.2)+ 结构化 artifact_type。
- Manager→Runtime 路径对齐 /api/agent/sub-agile/deployments(§2.2),{deployment_id} 回退 swarm_id。
- 状态裁决 display_status:Manager 唯一裁判,completed 无有效产物→needs_codegen/
completed_without_deliverable(§10.6),接入 detail/timeline/workflow。
- GET /api/heicode/capabilities 能力发现(§6)。
- 模型策略 per_role(role_models)+ 收集 allowed_model_ids(§9)。
- resource_binding_id→secret_ref 服务端解析,客户端不再 inline secret_ref(§17.6)。
- 客户端统一路由层 /api/heicode/sub-agile|swarm/*(task≡deployment,复用控制面)+ workflow 投影。
- 日志分层 user_logs/debug_logs(§13)。
验证:go build ./... + go test(controller/router/model/middleware)全绿;前端 tsc -b + rsbuild build 通过。
待部署:VM .env 的 AGNET_*→AGENT_*;启动迁移自动 rename 表;其他三仓库需同步切到 /api/agent。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
285 lines
11 KiB
Go
285 lines
11 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/heicode/manager/common"
|
|
"github.com/heicode/manager/model"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func setupAgentApprovalTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
common.UsingSQLite = true
|
|
common.UsingMySQL = false
|
|
common.UsingPostgreSQL = false
|
|
common.RedisEnabled = false
|
|
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
model.DB = db
|
|
model.LOG_DB = db
|
|
require.NoError(t, db.AutoMigrate(
|
|
&model.AgentApprovalRequest{},
|
|
&model.AgentCredentialLease{},
|
|
&model.AgentAuditEvent{},
|
|
&model.AgentDeployment{},
|
|
))
|
|
t.Cleanup(func() {
|
|
sqlDB, err := db.DB()
|
|
if err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
return db
|
|
}
|
|
|
|
func TestAgentApprovalApproveNotifiesRuntimeDecision(t *testing.T) {
|
|
db := setupAgentApprovalTestDB(t)
|
|
var runtimeBody string
|
|
var runtimeAuth string
|
|
var runtimePath string
|
|
runtimeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
runtimePath = r.URL.Path
|
|
runtimeAuth = r.Header.Get("Authorization")
|
|
body, _ := io.ReadAll(r.Body)
|
|
runtimeBody = string(body)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"data":{"status":"accepted"}}`))
|
|
}))
|
|
defer runtimeServer.Close()
|
|
t.Setenv("AGENT_RUNTIME_ENABLED", "true")
|
|
t.Setenv("AGENT_RUNTIME_BASE_URL", runtimeServer.URL)
|
|
t.Setenv("AGENT_RUNTIME_SERVICE_TOKEN", "runtime-token")
|
|
t.Setenv("AGENT_RUNTIME_APPROVAL_DECISION_PATH", "/api/swarms/{swarm_id}/approvals/{approval_id}")
|
|
|
|
require.NoError(t, db.Create(&model.AgentDeployment{
|
|
DeploymentID: "dep_runtime_approval",
|
|
UserID: "7",
|
|
BindingScope: "project-alpha",
|
|
CorrelationID: "corr-runtime-approval",
|
|
RuntimeDeploymentID: "runtime-dep-1",
|
|
RuntimeSwarmID: "swarm-runtime-1",
|
|
Status: "running",
|
|
RuntimeState: "running",
|
|
PlanJSON: `{"user_context":{"user_id":"7"},"metadata":{"correlation_id":"corr-runtime-approval"}}`,
|
|
}).Error)
|
|
|
|
body := `{
|
|
"deployment_id":"dep_runtime_approval",
|
|
"binding_scope":"project-alpha",
|
|
"operation":"write_repository",
|
|
"resource_id":"repo-main",
|
|
"resource_type":"git",
|
|
"resource_scope":"https://example.invalid/acme/repo#main",
|
|
"target_role":"backend",
|
|
"risk_level":"high",
|
|
"requires_credential":true,
|
|
"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/repo-main",
|
|
"ttl_seconds":600
|
|
}`
|
|
|
|
_, approvalID := createAgentApprovalForTest(t, 7, body)
|
|
w := performAgentApprovalRequest(ApproveAgentApprovalRequest, 7, http.MethodPost, "/approvals/:approval_id/approve", "/approvals/"+approvalID+"/approve", `{"reason":"允许写入"}`)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, "/api/swarms/swarm-runtime-1/approvals/"+approvalID, runtimePath)
|
|
require.Equal(t, "Bearer runtime-token", runtimeAuth)
|
|
require.Contains(t, runtimeBody, `"decision":"approved"`)
|
|
require.Contains(t, runtimeBody, `"approval_id":"`+approvalID+`"`)
|
|
require.Contains(t, runtimeBody, `"manager_deployment_id":"dep_runtime_approval"`)
|
|
require.Contains(t, runtimeBody, `"runtime_deployment_id":"runtime-dep-1"`)
|
|
require.Contains(t, runtimeBody, `"swarm_id":"swarm-runtime-1"`)
|
|
require.Contains(t, runtimeBody, `"credential_ref":"lease://agent/`)
|
|
require.NotContains(t, runtimeBody, "azkv://")
|
|
|
|
var auditRows []model.AgentAuditEvent
|
|
require.NoError(t, db.Where("deployment_id = ?", "dep_runtime_approval").Order("id asc").Find(&auditRows).Error)
|
|
require.NotEmpty(t, auditRows)
|
|
require.Equal(t, "runtime.approval_decision.accepted", auditRows[len(auditRows)-1].Event)
|
|
}
|
|
|
|
func performAgentApprovalRequest(handler gin.HandlerFunc, userID int, method string, routePath string, requestPath string, body string) *httptest.ResponseRecorder {
|
|
r := gin.New()
|
|
r.Handle(method, routePath, func(c *gin.Context) {
|
|
c.Set("id", userID)
|
|
handler(c)
|
|
})
|
|
req := httptest.NewRequest(method, requestPath, bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func decodeAgentApprovalEnvelope(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
|
t.Helper()
|
|
var envelope map[string]any
|
|
require.NoError(t, common.Unmarshal(w.Body.Bytes(), &envelope))
|
|
return envelope
|
|
}
|
|
|
|
func createAgentApprovalForTest(t *testing.T, userID int, body string) (map[string]any, string) {
|
|
t.Helper()
|
|
w := performAgentApprovalRequest(CreateAgentApprovalRequest, userID, http.MethodPost, "/approvals", "/approvals", body)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
envelope := decodeAgentApprovalEnvelope(t, w)
|
|
require.Equal(t, true, envelope["success"])
|
|
data := envelope["data"].(map[string]any)
|
|
approvalID := data["approval_id"].(string)
|
|
require.NotEmpty(t, approvalID)
|
|
return data, approvalID
|
|
}
|
|
|
|
func TestAgentApprovalApproveCreatesShortLivedLeaseAndAudit(t *testing.T) {
|
|
db := setupAgentApprovalTestDB(t)
|
|
body := `{
|
|
"deployment_id":"dep_approval_1",
|
|
"binding_scope":"project-alpha",
|
|
"operation":"write_repository",
|
|
"resource_id":"repo-main",
|
|
"resource_type":"git",
|
|
"resource_scope":"https://example.invalid/acme/repo#main",
|
|
"target_role":"backend",
|
|
"risk_level":"high",
|
|
"requires_credential":true,
|
|
"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/repo-main",
|
|
"ttl_seconds":600,
|
|
"reason":"需要写入功能分支"
|
|
}`
|
|
|
|
_, approvalID := createAgentApprovalForTest(t, 7, body)
|
|
w := performAgentApprovalRequest(ApproveAgentApprovalRequest, 7, http.MethodPost, "/approvals/:approval_id/approve", "/approvals/"+approvalID+"/approve", `{"reason":"允许本次任务"}`)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
envelope := decodeAgentApprovalEnvelope(t, w)
|
|
require.Equal(t, true, envelope["success"])
|
|
data := envelope["data"].(map[string]any)
|
|
require.Equal(t, "approved", data["status"])
|
|
lease := data["credential_lease"].(map[string]any)
|
|
require.Equal(t, "active", lease["status"])
|
|
require.Contains(t, lease["credential_ref"], "lease://agent/")
|
|
require.NotContains(t, w.Body.String(), "do-not-store")
|
|
require.NotContains(t, w.Body.String(), "azkv://", "API responses must not expose internal Key Vault secret_ref")
|
|
|
|
var storedLease model.AgentCredentialLease
|
|
require.NoError(t, db.Where("approval_id = ?", approvalID).First(&storedLease).Error)
|
|
require.Equal(t, "azkv://heicode-kv.vault.azure.net/secrets/repo-main", storedLease.SecretRef)
|
|
require.Equal(t, "active", storedLease.Status)
|
|
require.Greater(t, storedLease.ExpiresAt, storedLease.CreatedAt)
|
|
require.NotContains(t, storedLease.CredentialRef, "azkv://")
|
|
|
|
var auditRows []model.AgentAuditEvent
|
|
require.NoError(t, db.Order("id asc").Find(&auditRows).Error)
|
|
require.Len(t, auditRows, 3)
|
|
require.Equal(t, "approval.requested", auditRows[0].Event)
|
|
require.Equal(t, "approval.approved", auditRows[1].Event)
|
|
require.Equal(t, "credential_lease.created", auditRows[2].Event)
|
|
require.NotContains(t, auditRows[2].DetailsJSON, "repo-main-secret-value")
|
|
}
|
|
|
|
func TestAgentApprovalRejectDoesNotCreateLease(t *testing.T) {
|
|
db := setupAgentApprovalTestDB(t)
|
|
body := `{
|
|
"deployment_id":"dep_approval_2",
|
|
"binding_scope":"project-alpha",
|
|
"operation":"delete_resource",
|
|
"resource_id":"vm-prod",
|
|
"resource_type":"cloud_resource",
|
|
"resource_scope":"/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/prod",
|
|
"target_role":"ops",
|
|
"risk_level":"critical",
|
|
"requires_credential":true,
|
|
"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/azure-sp",
|
|
"ttl_seconds":300
|
|
}`
|
|
|
|
_, approvalID := createAgentApprovalForTest(t, 8, body)
|
|
w := performAgentApprovalRequest(RejectAgentApprovalRequest, 8, http.MethodPost, "/approvals/:approval_id/reject", "/approvals/"+approvalID+"/reject", `{"reason":"风险过高"}`)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
envelope := decodeAgentApprovalEnvelope(t, w)
|
|
require.Equal(t, true, envelope["success"])
|
|
data := envelope["data"].(map[string]any)
|
|
require.Equal(t, "rejected", data["status"])
|
|
|
|
var leases int64
|
|
require.NoError(t, db.Model(&model.AgentCredentialLease{}).Count(&leases).Error)
|
|
require.Equal(t, int64(0), leases)
|
|
}
|
|
|
|
func TestAgentApprovalExpiredRequestCannotBeApproved(t *testing.T) {
|
|
db := setupAgentApprovalTestDB(t)
|
|
body := `{
|
|
"deployment_id":"dep_approval_3",
|
|
"binding_scope":"project-alpha",
|
|
"operation":"write_repository",
|
|
"resource_id":"repo-main",
|
|
"resource_type":"git",
|
|
"resource_scope":"https://example.invalid/acme/repo#main",
|
|
"target_role":"backend",
|
|
"risk_level":"high",
|
|
"requires_credential":true,
|
|
"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/repo-main",
|
|
"ttl_seconds":1
|
|
}`
|
|
|
|
_, approvalID := createAgentApprovalForTest(t, 9, body)
|
|
require.NoError(t, db.Model(&model.AgentApprovalRequest{}).Where("approval_id = ?", approvalID).Updates(map[string]any{
|
|
"expires_at": 1,
|
|
}).Error)
|
|
|
|
w := performAgentApprovalRequest(ApproveAgentApprovalRequest, 9, http.MethodPost, "/approvals/:approval_id/approve", "/approvals/"+approvalID+"/approve", `{}`)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
envelope := decodeAgentApprovalEnvelope(t, w)
|
|
require.Equal(t, false, envelope["success"])
|
|
require.Contains(t, envelope["message"], "approval request expired")
|
|
|
|
var approval model.AgentApprovalRequest
|
|
require.NoError(t, db.Where("approval_id = ?", approvalID).First(&approval).Error)
|
|
require.Equal(t, "expired", approval.Status)
|
|
}
|
|
|
|
func TestAgentCredentialLeaseCanBeRevoked(t *testing.T) {
|
|
db := setupAgentApprovalTestDB(t)
|
|
body := `{
|
|
"deployment_id":"dep_approval_4",
|
|
"binding_scope":"project-alpha",
|
|
"operation":"write_repository",
|
|
"resource_id":"repo-main",
|
|
"resource_type":"git",
|
|
"resource_scope":"https://example.invalid/acme/repo#main",
|
|
"target_role":"backend",
|
|
"risk_level":"high",
|
|
"requires_credential":true,
|
|
"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/repo-main",
|
|
"ttl_seconds":600
|
|
}`
|
|
|
|
_, approvalID := createAgentApprovalForTest(t, 10, body)
|
|
w := performAgentApprovalRequest(ApproveAgentApprovalRequest, 10, http.MethodPost, "/approvals/:approval_id/approve", "/approvals/"+approvalID+"/approve", `{}`)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var storedLease model.AgentCredentialLease
|
|
require.NoError(t, db.Where("approval_id = ?", approvalID).First(&storedLease).Error)
|
|
|
|
revokePath := "/credential-leases/" + storedLease.LeaseID + "/revoke"
|
|
w = performAgentApprovalRequest(RevokeAgentCredentialLease, 10, http.MethodPost, "/credential-leases/:lease_id/revoke", revokePath, `{"reason":"任务结束"}`)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
envelope := decodeAgentApprovalEnvelope(t, w)
|
|
require.Equal(t, true, envelope["success"])
|
|
data := envelope["data"].(map[string]any)
|
|
require.Equal(t, "revoked", data["status"])
|
|
|
|
require.NoError(t, db.Where("lease_id = ?", storedLease.LeaseID).First(&storedLease).Error)
|
|
require.Equal(t, "revoked", storedLease.Status)
|
|
require.Greater(t, storedLease.RevokedAt, int64(0))
|
|
}
|