This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+114 -3
View File
@@ -24,17 +24,42 @@ import (
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
)
// DefaultPermissionTimeout 是未配置时的人工决策超时;到期默认拒绝。
const DefaultPermissionTimeout = 5 * time.Minute
// permissionTopic 是 HITL 待审权限通知的 topic(inbox + 跨通道路由用)。
const permissionTopic = "acp.permission_pending"
// PermissionNotifier 是 PermissionService 投递"待人工审批"通知所需的窄接口。
// notify.Dispatcher 满足它;用窄接口便于测试断言 Dispatch 调用。
type PermissionNotifier interface {
Dispatch(ctx context.Context, msg notify.Message) error
}
// PermissionMetrics 是 PermissionService 记录决策计数所需的窄接口。
// metrics.Metrics 满足它;nil 时不记录(部分测试 / metrics 关闭)。
type PermissionMetrics interface {
RecordPermissionDecision(outcome string)
}
// RelayLocator 让 PermissionService 按 session id 取到 *Relay 以推送 WS 事件。
// Supervisor 满足该接口(GetRelay)。用窄接口避免 permSvc 依赖整个 Supervisor。
type RelayLocator interface {
GetRelay(sid uuid.UUID) *Relay
}
// ProjectMaintainer 让 PermissionService 判定某用户是否对会话所属 project 有写权限
// (project maintainer:owner / global-admin / project admin|member 角色)。允许任意
// project maintainer 处理待审权限请求,而不仅是会话 owner(autonomy roadmap §11)。
// app 层用 projectAccessAdapter.ResolveByID 实现;nil 时退化为仅 owner/admin。
type ProjectMaintainer interface {
// CanWriteProject 报告 (callerID, isAdmin) 是否对 projectID 有写权限。
CanWriteProject(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error)
}
// decision 是投递给阻塞中 Decide 的人工/系统决策结果。
type decision struct {
optionID string
@@ -58,8 +83,27 @@ type PermissionService struct {
locMu sync.RWMutex
loc RelayLocator
// HITL:待审权限通知 + 决策计数。装配期经 SetNotifier/SetMetrics 注入,
// 二者均可为 nil(部分测试 / metrics 关闭)。
notify PermissionNotifier
metrics PermissionMetrics
// maintainer 允许任意 project maintainer(非仅会话 owner)处理待审权限请求。
// 装配期经 SetProjectMaintainer 注入;nil 时退化为仅 owner/global-admin。
maintainer ProjectMaintainer
}
// SetNotifier 注入待审权限通知投递器(装配期,best-effort)。
func (s *PermissionService) SetNotifier(n PermissionNotifier) { s.notify = n }
// SetMetrics 注入权限决策计数器(装配期)。
func (s *PermissionService) SetMetrics(m PermissionMetrics) { s.metrics = m }
// SetProjectMaintainer 注入 project maintainer 判定器(装配期)。注入后,会话
// 所属 project 的任意 maintainer 均可处理该会话的待审权限请求。
func (s *PermissionService) SetProjectMaintainer(m ProjectMaintainer) { s.maintainer = m }
// NewPermissionService 构造 PermissionService。timeout<=0 时用 DefaultPermissionTimeout。
func NewPermissionService(repo Repository, rec audit.Recorder, timeout time.Duration, log *slog.Logger) *PermissionService {
if log == nil {
@@ -109,6 +153,7 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon
s.log.Error("acp.permission.insert_auto", "session_id", sess.SessionID, "err", err.Error())
}
s.recordDecision(ctx, sess.UserID, sess.SessionID, toolName, "auto", nil)
s.recordMetric("auto")
return chosen
}
@@ -140,6 +185,10 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon
Payload: mustJSON(permissionRequestPayload(row)),
})
// HITL:在阻塞前 best-effort 推送 inbox 通知,使关闭页面的用户也能被提醒去
// /approvals 审批(fire-and-forget,错误不影响 deny-on-timeout 安全默认)。
s.notifyPending(ctx, sess, row, toolName)
select {
case d := <-w.ch:
return d.optionID
@@ -147,6 +196,7 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon
// 超时 → CAS 标记 expired(系统决策,decided_by=NULL)。
if _, ok, _ := s.repo.DecidePermissionRequest(context.WithoutCancel(ctx), reqID, PermissionExpired, nil, nil); ok {
s.fanout(sess.SessionID, resolvedEnvelope(reqID, PermissionExpired, ""))
s.recordMetric("expired")
}
return ""
case <-ctx.Done():
@@ -155,7 +205,8 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon
}
}
// Resolve 落地一次人工决策(approve/deny)。鉴权:session owneradmin
// Resolve 落地一次人工决策(approve/deny)。鉴权:session owner、global-admin,或
// 会话所属 project 的任意 maintainer(autonomy roadmap §11)。
func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UUID, approve bool, optionID string) error {
req, err := s.repo.GetPermissionRequestByID(ctx, reqID)
if err != nil {
@@ -165,7 +216,7 @@ func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UU
if err != nil {
return err
}
if !c.IsAdmin && sessRow.UserID != c.UserID {
if !s.canManageSession(ctx, c, sessRow) {
return errs.New(errs.CodeAcpSessionNotOwned, "无权处理该会话的权限请求")
}
@@ -200,21 +251,50 @@ func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UU
s.wake(reqID, decision{optionID: chosen})
s.fanout(req.SessionID, resolvedEnvelope(reqID, status, chosen))
s.recordDecision(ctx, c.UserID, req.SessionID, req.ToolName, string(status), &c.UserID)
if approve {
s.recordMetric("approved")
} else {
s.recordMetric("denied")
}
return nil
}
// ListPendingForUser 返回该用户跨所有会话的待审权限请求(HITL 跨会话审批 inbox)。
// 包装 repo.ListPendingPermissionRequestsByUser(已 JOIN acp_sessions.user_id)。
// admin 仅看自己拥有会话的待审项(与现有 inbox 语义一致;全局视图走 dashboard)。
func (s *PermissionService) ListPendingForUser(ctx context.Context, c Caller) ([]*PermissionRequest, error) {
return s.repo.ListPendingPermissionRequestsByUser(ctx, c.UserID)
}
// ListPendingBySession 返回某会话的待审请求。鉴权同 Resolve。
func (s *PermissionService) ListPendingBySession(ctx context.Context, c Caller, sessionID uuid.UUID) ([]*PermissionRequest, error) {
sessRow, err := s.repo.GetSessionByID(ctx, sessionID)
if err != nil {
return nil, err
}
if !c.IsAdmin && sessRow.UserID != c.UserID {
if !s.canManageSession(ctx, c, sessRow) {
return nil, errs.New(errs.CodeAcpSessionNotOwned, "无权查看该会话")
}
return s.repo.ListPendingPermissionRequestsBySession(ctx, sessionID)
}
// canManageSession 报告 caller 是否可处理/查看该会话的权限请求:session owner、
// global-admin,或会话所属 project 的任意 maintainer(注入 ProjectMaintainer 时)。
// maintainer 查询失败按"无权"处理(fail-closed),但 owner/admin 短路不受影响。
func (s *PermissionService) canManageSession(ctx context.Context, c Caller, sess *Session) bool {
if c.IsAdmin || sess.UserID == c.UserID {
return true
}
if s.maintainer == nil {
return false
}
ok, err := s.maintainer.CanWriteProject(ctx, c.UserID, c.IsAdmin, sess.ProjectID)
if err != nil {
return false
}
return ok
}
// CancelSession 在会话终止时把其全部 pending 请求标 expired,并唤醒阻塞的 Decide
// goroutine 使其立即返回(agent 收默认拒绝)。supervisor.onExit 调用。
func (s *PermissionService) CancelSession(ctx context.Context, sessionID uuid.UUID) {
@@ -291,6 +371,37 @@ func (s *PermissionService) recordDecision(ctx context.Context, actor, sessionID
})
}
// recordMetric 记录一次权限决策计数(auto/approved/denied/expired)。metrics 为 nil 时 no-op。
func (s *PermissionService) recordMetric(outcome string) {
if s.metrics == nil {
return
}
s.metrics.RecordPermissionDecision(outcome)
}
// notifyPending best-effort 向会话所有者推送"待人工审批"通知。错误仅记日志,
// 绝不影响 Decide 的阻塞 / deny-on-timeout 安全默认。
func (s *PermissionService) notifyPending(ctx context.Context, sess handlers.SessionContext, row *PermissionRequest, toolName string) {
if s.notify == nil {
return
}
if err := s.notify.Dispatch(context.WithoutCancel(ctx), notify.Message{
UserID: sess.UserID,
Topic: permissionTopic,
Severity: notify.SeverityWarning,
Title: "Approval needed",
Body: "An agent is requesting permission to run a tool and needs your approval.",
Link: "/approvals",
Metadata: map[string]any{
"request_id": row.ID.String(),
"session_id": sess.SessionID.String(),
"tool_name": toolName,
},
}); err != nil {
s.log.Warn("acp.permission.notify_pending_failed", "session_id", sess.SessionID, "err", err.Error())
}
}
func permissionRequestPayload(p *PermissionRequest) map[string]any {
return map[string]any{
"request_id": p.ID.String(),