You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -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 owner 或 admin。
|
||||
// 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(),
|
||||
|
||||
Reference in New Issue
Block a user