You've already forked agentic-coding-workflow
修改
This commit is contained in:
@@ -37,10 +37,15 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK
|
||||
if args == nil {
|
||||
args = []string{}
|
||||
}
|
||||
allowlist := in.ToolAllowlist
|
||||
if allowlist == nil {
|
||||
allowlist = []string{}
|
||||
}
|
||||
k := &AgentKind{
|
||||
ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description,
|
||||
BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled,
|
||||
CreatedBy: c.UserID,
|
||||
ToolAllowlist: allowlist,
|
||||
CreatedBy: c.UserID,
|
||||
}
|
||||
out, err := s.repo.CreateAgentKind(ctx, k)
|
||||
if err != nil {
|
||||
@@ -105,6 +110,10 @@ func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, i
|
||||
cur.EncryptedEnv = encrypted
|
||||
changed["env"] = "<changed>"
|
||||
}
|
||||
if in.ToolAllowlist != nil {
|
||||
cur.ToolAllowlist = in.ToolAllowlist
|
||||
changed["tool_allowlist"] = "<changed>"
|
||||
}
|
||||
if in.Enabled != nil && *in.Enabled != cur.Enabled {
|
||||
cur.Enabled = *in.Enabled
|
||||
changed["enabled"] = *in.Enabled
|
||||
|
||||
@@ -256,3 +256,26 @@ func TestSupervisor_BuildSpawnEnv_ExtraEnvOverrides(t *testing.T) {
|
||||
assert.Contains(t, env, "OTHER=kept")
|
||||
assert.Contains(t, env, "ACW_MCP_URL=http://x")
|
||||
}
|
||||
|
||||
// ===== PermissionRequest(权限审批框架,测试桩) =====
|
||||
func (f *fakeAgentKindRepo) InsertPermissionRequest(context.Context, *acp.PermissionRequest) (*acp.PermissionRequest, error) {
|
||||
return &acp.PermissionRequest{}, nil
|
||||
}
|
||||
func (f *fakeAgentKindRepo) GetPermissionRequestByID(context.Context, uuid.UUID) (*acp.PermissionRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentKindRepo) ListPendingPermissionRequestsBySession(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentKindRepo) ListPendingPermissionRequestsByUser(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentKindRepo) DecidePermissionRequest(context.Context, uuid.UUID, acp.PermissionStatus, *string, *uuid.UUID) (*acp.PermissionRequest, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
func (f *fakeAgentKindRepo) ExpirePendingPermissionRequestsBySession(context.Context, uuid.UUID) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeAgentKindRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
+59
-30
@@ -58,6 +58,7 @@ type AgentKind struct {
|
||||
Args []string
|
||||
EncryptedEnv []byte
|
||||
Enabled bool
|
||||
ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示全部放行
|
||||
CreatedBy uuid.UUID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
@@ -66,23 +67,23 @@ type AgentKind struct {
|
||||
// Session 是一次 ACP 会话。AgentSessionID 是 agent 侧的 session 标识(来自
|
||||
// session/new response),与本表 ID 不同,仅用于 ACP 协议 method 内填充。
|
||||
type Session struct {
|
||||
ID uuid.UUID
|
||||
WorkspaceID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
AgentKindID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
IssueID *uuid.UUID
|
||||
RequirementID *uuid.UUID
|
||||
AgentSessionID *string
|
||||
Branch string
|
||||
CwdPath string
|
||||
IsMainWorktree bool
|
||||
Status SessionStatus
|
||||
PID *int32
|
||||
ExitCode *int32
|
||||
LastError *string
|
||||
StartedAt time.Time
|
||||
EndedAt *time.Time
|
||||
ID uuid.UUID
|
||||
WorkspaceID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
AgentKindID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
IssueID *uuid.UUID
|
||||
RequirementID *uuid.UUID
|
||||
AgentSessionID *string
|
||||
Branch string
|
||||
CwdPath string
|
||||
IsMainWorktree bool
|
||||
Status SessionStatus
|
||||
PID *int32
|
||||
ExitCode *int32
|
||||
LastError *string
|
||||
StartedAt time.Time
|
||||
EndedAt *time.Time
|
||||
}
|
||||
|
||||
// Event 是一条入库的 JSON-RPC 消息。Payload 是 JSON-RPC envelope 完整 JSON
|
||||
@@ -136,22 +137,50 @@ type CreateSessionInput struct {
|
||||
|
||||
// CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。
|
||||
type CreateAgentKindInput struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
Description string
|
||||
BinaryPath string
|
||||
Args []string
|
||||
Env map[string]string
|
||||
Enabled bool
|
||||
Name string
|
||||
DisplayName string
|
||||
Description string
|
||||
BinaryPath string
|
||||
Args []string
|
||||
Env map[string]string
|
||||
Enabled bool
|
||||
ToolAllowlist []string
|
||||
}
|
||||
|
||||
// UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。
|
||||
// Env 三态:nil = 不修改;空 map = 清空全部 env;非空 = 整体替换。
|
||||
type UpdateAgentKindInput struct {
|
||||
DisplayName *string
|
||||
Description *string
|
||||
BinaryPath *string
|
||||
Args []string // nil = 不改;非 nil = 替换
|
||||
Env map[string]string // nil = 不改;非 nil(含空)= 替换
|
||||
Enabled *bool
|
||||
DisplayName *string
|
||||
Description *string
|
||||
BinaryPath *string
|
||||
Args []string // nil = 不改;非 nil = 替换
|
||||
Env map[string]string // nil = 不改;非 nil(含空)= 替换
|
||||
Enabled *bool
|
||||
ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换
|
||||
}
|
||||
|
||||
// PermissionStatus 是 acp_permission_requests.status 枚举。
|
||||
type PermissionStatus string
|
||||
|
||||
const (
|
||||
PermissionPending PermissionStatus = "pending"
|
||||
PermissionApproved PermissionStatus = "approved"
|
||||
PermissionDenied PermissionStatus = "denied"
|
||||
PermissionAuto PermissionStatus = "auto"
|
||||
PermissionExpired PermissionStatus = "expired"
|
||||
)
|
||||
|
||||
// PermissionRequest 是一条代理发起的工具调用授权请求记录。
|
||||
type PermissionRequest struct {
|
||||
ID uuid.UUID
|
||||
SessionID uuid.UUID
|
||||
AgentRequestID string
|
||||
ToolName string
|
||||
ToolCall []byte // 原始 toolCall JSON
|
||||
Options []byte // 原始 options JSON
|
||||
Status PermissionStatus
|
||||
ChosenOptionID *string
|
||||
DecidedBy *uuid.UUID
|
||||
DecidedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
+53
-24
@@ -1,22 +1,26 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AgentKindAdminDTO 是 admin 视图的完整字段。EnvKeys 仅返回 key 列表(不返回值)。
|
||||
type AgentKindAdminDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EnvKeys []string `json:"env_keys"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EnvKeys []string `json:"env_keys"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AgentKindPublicDTO 是普通用户视图,不暴露 binary_path / args / env_keys。
|
||||
@@ -29,22 +33,47 @@ type AgentKindPublicDTO struct {
|
||||
|
||||
// CreateAgentKindReq / UpdateAgentKindReq 是 HTTP 请求体。
|
||||
type CreateAgentKindReq struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
}
|
||||
|
||||
type UpdateAgentKindReq struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BinaryPath *string `json:"binary_path,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BinaryPath *string `json:"binary_path,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionRequestDTO 是权限请求的对外视图。
|
||||
type PermissionRequestDTO struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolCall json.RawMessage `json:"tool_call"`
|
||||
Options json.RawMessage `json:"options"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func permissionRequestToDTO(p *PermissionRequest) PermissionRequestDTO {
|
||||
return PermissionRequestDTO{
|
||||
ID: p.ID.String(),
|
||||
SessionID: p.SessionID.String(),
|
||||
ToolName: p.ToolName,
|
||||
ToolCall: json.RawMessage(p.ToolCall),
|
||||
Options: json.RawMessage(p.Options),
|
||||
Status: string(p.Status),
|
||||
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSessionReq is the request body for POST /api/v1/acp/sessions.
|
||||
|
||||
+98
-26
@@ -42,6 +42,7 @@ type Handler struct {
|
||||
sessSvc SessionService
|
||||
sup *Supervisor
|
||||
repo Repository
|
||||
permSvc *PermissionService
|
||||
resolver middleware.SessionResolver
|
||||
adminLookup AdminLookup
|
||||
enc *crypto.Encryptor
|
||||
@@ -50,9 +51,10 @@ type Handler struct {
|
||||
|
||||
// NewHandler constructs Handler with full dependencies.
|
||||
func NewHandler(ak AgentKindService, ss SessionService, sup *Supervisor, repo Repository,
|
||||
resolver middleware.SessionResolver, al AdminLookup, enc *crypto.Encryptor, cfg WSConfig) *Handler {
|
||||
permSvc *PermissionService, resolver middleware.SessionResolver, al AdminLookup,
|
||||
enc *crypto.Encryptor, cfg WSConfig) *Handler {
|
||||
return &Handler{
|
||||
akSvc: ak, sessSvc: ss, sup: sup, repo: repo,
|
||||
akSvc: ak, sessSvc: ss, sup: sup, repo: repo, permSvc: permSvc,
|
||||
resolver: resolver, adminLookup: al, enc: enc, cfg: cfg,
|
||||
}
|
||||
}
|
||||
@@ -75,7 +77,74 @@ func (h *Handler) Mount(r chi.Router) {
|
||||
r.Delete("/{id}", h.terminateSession)
|
||||
r.Get("/{id}/events", h.getEvents)
|
||||
r.Get("/{id}/ws", h.sessionWS)
|
||||
r.Get("/{id}/permissions", h.listSessionPermissions)
|
||||
})
|
||||
r.Route("/api/v1/acp/permissions", func(r chi.Router) {
|
||||
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
||||
r.Post("/{reqID}/approve", h.approvePermission)
|
||||
r.Post("/{reqID}/deny", h.denyPermission)
|
||||
})
|
||||
}
|
||||
|
||||
// listSessionPermissions 返回某会话的待审权限请求(owner 或 admin)。
|
||||
func (h *Handler) listSessionPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
id, perr := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if perr != nil {
|
||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
|
||||
return
|
||||
}
|
||||
reqs, err := h.permSvc.ListPendingBySession(r.Context(), c, id)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
out := make([]PermissionRequestDTO, 0, len(reqs))
|
||||
for _, p := range reqs {
|
||||
out = append(out, permissionRequestToDTO(p))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type approvePermissionReq struct {
|
||||
OptionID string `json:"option_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) approvePermission(w http.ResponseWriter, r *http.Request) {
|
||||
h.resolvePermission(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) denyPermission(w http.ResponseWriter, r *http.Request) {
|
||||
h.resolvePermission(w, r, false)
|
||||
}
|
||||
|
||||
func (h *Handler) resolvePermission(w http.ResponseWriter, r *http.Request, approve bool) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
reqID, perr := uuid.Parse(chi.URLParam(r, "reqID"))
|
||||
if perr != nil {
|
||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad request id"))
|
||||
return
|
||||
}
|
||||
var optionID string
|
||||
if approve {
|
||||
var body approvePermissionReq
|
||||
// 批准时 option_id 可选(缺省自动挑选 allow 类);请求体可空。
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
optionID = body.OptionID
|
||||
}
|
||||
if err := h.permSvc.Resolve(r.Context(), c, reqID, approve, optionID); err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) caller(r *http.Request) (Caller, error) {
|
||||
@@ -132,13 +201,14 @@ func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
in := CreateAgentKindInput{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
DisplayName: req.DisplayName,
|
||||
Description: req.Description,
|
||||
BinaryPath: strings.TrimSpace(req.BinaryPath),
|
||||
Args: req.Args,
|
||||
Env: req.Env,
|
||||
Enabled: enabled,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
DisplayName: req.DisplayName,
|
||||
Description: req.Description,
|
||||
BinaryPath: strings.TrimSpace(req.BinaryPath),
|
||||
Args: req.Args,
|
||||
Env: req.Env,
|
||||
Enabled: enabled,
|
||||
ToolAllowlist: req.ToolAllowlist,
|
||||
}
|
||||
out, err := h.akSvc.Create(r.Context(), c, in)
|
||||
if err != nil {
|
||||
@@ -188,12 +258,13 @@ func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
in := UpdateAgentKindInput{
|
||||
DisplayName: req.DisplayName,
|
||||
Description: req.Description,
|
||||
BinaryPath: req.BinaryPath,
|
||||
Args: req.Args,
|
||||
Env: req.Env,
|
||||
Enabled: req.Enabled,
|
||||
DisplayName: req.DisplayName,
|
||||
Description: req.Description,
|
||||
BinaryPath: req.BinaryPath,
|
||||
Args: req.Args,
|
||||
Env: req.Env,
|
||||
Enabled: req.Enabled,
|
||||
ToolAllowlist: req.ToolAllowlist,
|
||||
}
|
||||
out, err := h.akSvc.Update(r.Context(), c, id, in)
|
||||
if err != nil {
|
||||
@@ -539,17 +610,18 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
|
||||
}
|
||||
}
|
||||
return AgentKindAdminDTO{
|
||||
ID: k.ID,
|
||||
Name: k.Name,
|
||||
DisplayName: k.DisplayName,
|
||||
Description: k.Description,
|
||||
BinaryPath: k.BinaryPath,
|
||||
Args: append([]string(nil), k.Args...),
|
||||
EnvKeys: envKeys,
|
||||
Enabled: k.Enabled,
|
||||
CreatedBy: k.CreatedBy,
|
||||
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
ID: k.ID,
|
||||
Name: k.Name,
|
||||
DisplayName: k.DisplayName,
|
||||
Description: k.Description,
|
||||
BinaryPath: k.BinaryPath,
|
||||
Args: append([]string(nil), k.Args...),
|
||||
EnvKeys: envKeys,
|
||||
Enabled: k.Enabled,
|
||||
ToolAllowlist: append([]string(nil), k.ToolAllowlist...),
|
||||
CreatedBy: k.CreatedBy,
|
||||
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,9 +155,12 @@ func TestHandler_Session_CreateAndWS_E2E(t *testing.T) {
|
||||
nil, // mcpTokens — not exercised by handler e2e
|
||||
)
|
||||
|
||||
permSvc := acp.NewPermissionService(repo, nil, 0, nil)
|
||||
sup.SetPermissionService(permSvc)
|
||||
permSvc.SetRelayLocator(sup)
|
||||
h := acp.NewHandler(
|
||||
nil, // ak svc — not exercised by sessions endpoints
|
||||
svc, sup, repo,
|
||||
svc, sup, repo, permSvc,
|
||||
fakeResolver{uid: uid},
|
||||
fakeAdminLookup{admin: map[uuid.UUID]bool{uid: false}},
|
||||
enc,
|
||||
|
||||
@@ -49,6 +49,7 @@ func mountHandlerWithRepo(t *testing.T, callerUID uuid.UUID, isAdmin bool, repo
|
||||
nil, // sessSvc — not exercised by agent-kinds tests
|
||||
nil, // sup — not exercised by agent-kinds tests
|
||||
nil, // repo — not exercised by agent-kinds tests
|
||||
nil, // permSvc — not exercised by agent-kinds tests
|
||||
fakeResolver{uid: callerUID},
|
||||
fakeAdminLookup{admin: map[uuid.UUID]bool{callerUID: isAdmin}},
|
||||
enc,
|
||||
|
||||
@@ -22,7 +22,8 @@ type SessionContext struct {
|
||||
WorkspaceID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
CwdPath string
|
||||
AgentSessionID *string // 可能 nil(handshake 未完成时不应触发 fs/* 但兜底)
|
||||
AgentSessionID *string // 可能 nil(handshake 未完成时不应触发 fs/* 但兜底)
|
||||
ToolAllowlist []string // agent kind 配置的工具白名单,命中则自动放行
|
||||
}
|
||||
|
||||
// FsResult 是 fs/* method 处理后返回给 relay 的结果。要么 OK(带 result),
|
||||
@@ -48,4 +49,3 @@ type FsHandler struct {
|
||||
func NewFsHandler() *FsHandler {
|
||||
return &FsHandler{Read: NewFsReadHandler(), Write: NewFsWriteHandler()}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,23 +8,38 @@ import (
|
||||
|
||||
// PermissionHandler handles session/request_permission (spec §5.1).
|
||||
//
|
||||
// MVP behaviour (decision §1 #8 + §10 misc #1): fs.* tool calls are answered
|
||||
// directly by the server (so they never reach this handler). Other tool calls
|
||||
// default to "reject_once" — fully automated, no human-in-the-loop.
|
||||
type PermissionHandler struct{}
|
||||
// 行为分两层:
|
||||
// - fs.* 工具调用由服务端直接应答(不经过本 handler)。
|
||||
// - 其他工具调用:若注入了 PermissionDecider(生产路径),交由其裁决——命中
|
||||
// agent kind 白名单则自动放行,否则挂起等待人工审批;未注入 decider 时
|
||||
// (部分单测/向后兼容)回退到「默认拒绝」的纯本地逻辑。
|
||||
type PermissionHandler struct {
|
||||
decider PermissionDecider
|
||||
}
|
||||
|
||||
// NewPermissionHandler constructs a PermissionHandler.
|
||||
func NewPermissionHandler() *PermissionHandler { return &PermissionHandler{} }
|
||||
// PermOption 是 session/request_permission 提供的一个候选选项。
|
||||
type PermOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionDecider 是权限裁决器契约。实现放在 internal/acp 主包(PermissionService),
|
||||
// 以接口注入避免 handlers 子包反向 import 主包形成循环。返回选中的 option id;
|
||||
// 空串表示拒绝。
|
||||
type PermissionDecider interface {
|
||||
Decide(ctx context.Context, sess SessionContext, agentRequestID string,
|
||||
toolCall json.RawMessage, options []PermOption) (chosenOptionID string)
|
||||
}
|
||||
|
||||
// NewPermissionHandler 构造 PermissionHandler。decider 可为 nil(回退到本地默认拒绝)。
|
||||
func NewPermissionHandler(decider PermissionDecider) *PermissionHandler {
|
||||
return &PermissionHandler{decider: decider}
|
||||
}
|
||||
|
||||
type permissionParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ToolCall json.RawMessage `json:"toolCall"`
|
||||
Options []permOption `json:"options"`
|
||||
}
|
||||
|
||||
type permOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Options []PermOption `json:"options"`
|
||||
}
|
||||
|
||||
type permOutcomeSelected struct {
|
||||
@@ -39,25 +54,21 @@ type permResult struct {
|
||||
Outcome permOutcome `json:"outcome"`
|
||||
}
|
||||
|
||||
// Handle picks the first option whose ID looks like a "reject" choice; if none
|
||||
// matches, falls back to the first option. Aligns with spec §10 misc #4
|
||||
// ("default reject").
|
||||
func (h *PermissionHandler) Handle(_ context.Context, _ SessionContext, paramsJSON json.RawMessage) FsResult {
|
||||
// Handle 解析参数后委托 decider 裁决;无 decider 时回退到本地默认拒绝逻辑。
|
||||
// agentRequestID 是代理侧 JSON-RPC request id,用于持久化与排障。
|
||||
func (h *PermissionHandler) Handle(ctx context.Context, sess SessionContext, agentRequestID string, paramsJSON json.RawMessage) FsResult {
|
||||
var p permissionParams
|
||||
if err := json.Unmarshal(paramsJSON, &p); err != nil {
|
||||
return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}}
|
||||
}
|
||||
chosen := ""
|
||||
for _, opt := range p.Options {
|
||||
lc := strings.ToLower(opt.ID)
|
||||
if strings.Contains(lc, "reject") || strings.Contains(lc, "deny") || strings.Contains(lc, "no") {
|
||||
chosen = opt.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == "" && len(p.Options) > 0 {
|
||||
chosen = p.Options[0].ID
|
||||
|
||||
var chosen string
|
||||
if h.decider != nil {
|
||||
chosen = h.decider.Decide(ctx, sess, agentRequestID, p.ToolCall, p.Options)
|
||||
} else {
|
||||
chosen = defaultRejectChoice(p.Options)
|
||||
}
|
||||
|
||||
if chosen == "" {
|
||||
out, _ := json.Marshal(permResult{Outcome: permOutcome{}})
|
||||
return FsResult{OK: out}
|
||||
@@ -65,3 +76,14 @@ func (h *PermissionHandler) Handle(_ context.Context, _ SessionContext, paramsJS
|
||||
out, _ := json.Marshal(permResult{Outcome: permOutcome{Selected: &permOutcomeSelected{ID: chosen}}})
|
||||
return FsResult{OK: out}
|
||||
}
|
||||
|
||||
// defaultRejectChoice 是无 decider 时的回退:优先选 reject 类选项,否则返回空(拒绝)。
|
||||
func defaultRejectChoice(options []PermOption) string {
|
||||
for _, opt := range options {
|
||||
lc := strings.ToLower(opt.ID)
|
||||
if strings.Contains(lc, "reject") || strings.Contains(lc, "deny") || strings.Contains(lc, "no") {
|
||||
return opt.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ import (
|
||||
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
|
||||
)
|
||||
|
||||
// 无 decider(nil)时回退到「默认拒绝」逻辑:优先选 reject 类选项。
|
||||
func TestPermission_AutoReject(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := handlers.NewPermissionHandler()
|
||||
h := handlers.NewPermissionHandler(nil)
|
||||
params := mustJSON(t, map[string]any{
|
||||
"sessionId": "x",
|
||||
"toolCall": map[string]any{"name": "shell.exec"},
|
||||
@@ -22,7 +23,7 @@ func TestPermission_AutoReject(t *testing.T) {
|
||||
map[string]any{"id": "reject_once"},
|
||||
},
|
||||
})
|
||||
res := h.Handle(context.Background(), handlers.SessionContext{}, params)
|
||||
res := h.Handle(context.Background(), handlers.SessionContext{}, "", params)
|
||||
require.Nil(t, res.Err)
|
||||
|
||||
var out struct {
|
||||
@@ -36,9 +37,10 @@ func TestPermission_AutoReject(t *testing.T) {
|
||||
assert.Equal(t, "reject_once", out.Outcome.Selected.ID)
|
||||
}
|
||||
|
||||
func TestPermission_NoRejectOption_FallbackFirst(t *testing.T) {
|
||||
// 无 reject 类选项且无 decider 时,默认拒绝(返回空 outcome,不再误选 allow)。
|
||||
func TestPermission_NoRejectOption_DefaultsToReject(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := handlers.NewPermissionHandler()
|
||||
h := handlers.NewPermissionHandler(nil)
|
||||
params := mustJSON(t, map[string]any{
|
||||
"sessionId": "x",
|
||||
"options": []any{
|
||||
@@ -46,23 +48,16 @@ func TestPermission_NoRejectOption_FallbackFirst(t *testing.T) {
|
||||
map[string]any{"id": "allow_session"},
|
||||
},
|
||||
})
|
||||
res := h.Handle(context.Background(), handlers.SessionContext{}, params)
|
||||
var out struct {
|
||||
Outcome struct {
|
||||
Selected struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"selected"`
|
||||
} `json:"outcome"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(res.OK, &out))
|
||||
assert.Equal(t, "allow_once", out.Outcome.Selected.ID)
|
||||
res := h.Handle(context.Background(), handlers.SessionContext{}, "", params)
|
||||
require.Nil(t, res.Err)
|
||||
assert.JSONEq(t, `{"outcome":{}}`, string(res.OK))
|
||||
}
|
||||
|
||||
func TestPermission_NoOptions_EmptyOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := handlers.NewPermissionHandler()
|
||||
h := handlers.NewPermissionHandler(nil)
|
||||
params := mustJSON(t, map[string]any{"sessionId": "x"})
|
||||
res := h.Handle(context.Background(), handlers.SessionContext{}, params)
|
||||
res := h.Handle(context.Background(), handlers.SessionContext{}, "", params)
|
||||
require.Nil(t, res.Err)
|
||||
assert.JSONEq(t, `{"outcome":{}}`, string(res.OK))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// permission.go 实现 ACP fs/* method 的路径安全校验。
|
||||
//
|
||||
// 规则(spec §5.6):
|
||||
// 1. 路径必须以 cwd_path 为前缀(防 ../../etc/passwd)
|
||||
// 2. 解析 symlink 后的真实路径必须仍在 cwd_path 下(防 symlink 逃逸)
|
||||
// 1. 路径必须以 cwd_path 为前缀(防 ../../etc/passwd)
|
||||
// 2. 解析 symlink 后的真实路径必须仍在 cwd_path 下(防 symlink 逃逸)
|
||||
//
|
||||
// 三重校验:filepath.Abs + strings.HasPrefix + filepath.EvalSymlinks。
|
||||
package acp
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
// permission_service.go 实装 ACP 工具调用权限审批框架。
|
||||
//
|
||||
// 代理通过 session/request_permission 发起工具调用授权请求,relay 为每个请求起
|
||||
// 独立 goroutine 调用 PermissionHandler.Handle → PermissionService.Decide。Decide
|
||||
// 分两条路径:
|
||||
// - 命中 agent kind 的 tool_allowlist:自动放行(status=auto),立即返回。
|
||||
// - 未命中:落库为 pending、通过 WS 推送 permission_request 事件、阻塞等待人工
|
||||
// 决策(Resolve)/ 超时(默认拒绝)/ 会话终止(CancelSession)。
|
||||
//
|
||||
// 阻塞安全:relay.handleAgentRequest 对每个 agent 请求起独立 goroutine,因此在
|
||||
// Decide 内阻塞不会卡住 reader 主循环;ctx 来自该请求的 relay.Run,会话关闭时取消。
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// DefaultPermissionTimeout 是未配置时的人工决策超时;到期默认拒绝。
|
||||
const DefaultPermissionTimeout = 5 * time.Minute
|
||||
|
||||
// RelayLocator 让 PermissionService 按 session id 取到 *Relay 以推送 WS 事件。
|
||||
// Supervisor 满足该接口(GetRelay)。用窄接口避免 permSvc 依赖整个 Supervisor。
|
||||
type RelayLocator interface {
|
||||
GetRelay(sid uuid.UUID) *Relay
|
||||
}
|
||||
|
||||
// decision 是投递给阻塞中 Decide 的人工/系统决策结果。
|
||||
type decision struct {
|
||||
optionID string
|
||||
}
|
||||
|
||||
// waiter 关联一个挂起的权限请求与其所属会话,便于会话终止时定向清理。
|
||||
type waiter struct {
|
||||
ch chan decision
|
||||
sessionID uuid.UUID
|
||||
}
|
||||
|
||||
// PermissionService 是进程级单例,注入到所有 relay 的 PermissionHandler。
|
||||
type PermissionService struct {
|
||||
repo Repository
|
||||
audit audit.Recorder
|
||||
log *slog.Logger
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
waiters map[uuid.UUID]*waiter // requestID -> waiter
|
||||
|
||||
locMu sync.RWMutex
|
||||
loc RelayLocator
|
||||
}
|
||||
|
||||
// NewPermissionService 构造 PermissionService。timeout<=0 时用 DefaultPermissionTimeout。
|
||||
func NewPermissionService(repo Repository, rec audit.Recorder, timeout time.Duration, log *slog.Logger) *PermissionService {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultPermissionTimeout
|
||||
}
|
||||
return &PermissionService{
|
||||
repo: repo,
|
||||
audit: rec,
|
||||
log: log,
|
||||
timeout: timeout,
|
||||
waiters: map[uuid.UUID]*waiter{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetRelayLocator 注入 relay 定位器。因 Supervisor 与 PermissionService 互相依赖,
|
||||
// 装配时先构造二者再回填(与 workspace CloneRunner.SetScheduler 同模式)。
|
||||
func (s *PermissionService) SetRelayLocator(loc RelayLocator) {
|
||||
s.locMu.Lock()
|
||||
s.loc = loc
|
||||
s.locMu.Unlock()
|
||||
}
|
||||
|
||||
// Decide 实现 handlers.PermissionDecider。返回选中的 option id;空串表示拒绝。
|
||||
func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionContext, agentReqID string,
|
||||
toolCall json.RawMessage, options []handlers.PermOption) string {
|
||||
|
||||
toolName := extractToolName(toolCall)
|
||||
optionsJSON, _ := json.Marshal(options)
|
||||
|
||||
// 路径一:白名单命中 → 自动放行。
|
||||
if allowlistHit(sess.ToolAllowlist, toolName) {
|
||||
chosen := chooseAllowOption(options)
|
||||
_, err := s.repo.InsertPermissionRequest(ctx, &PermissionRequest{
|
||||
ID: uuid.New(),
|
||||
SessionID: sess.SessionID,
|
||||
AgentRequestID: agentReqID,
|
||||
ToolName: toolName,
|
||||
ToolCall: jsonOrNull(toolCall),
|
||||
Options: optionsJSON,
|
||||
Status: PermissionAuto,
|
||||
ChosenOptionID: strPtrOrNil(chosen),
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Error("acp.permission.insert_auto", "session_id", sess.SessionID, "err", err.Error())
|
||||
}
|
||||
s.recordDecision(ctx, sess.UserID, sess.SessionID, toolName, "auto", nil)
|
||||
return chosen
|
||||
}
|
||||
|
||||
// 路径二:落库 pending 并阻塞等待决策。
|
||||
reqID := uuid.New()
|
||||
row, err := s.repo.InsertPermissionRequest(ctx, &PermissionRequest{
|
||||
ID: reqID,
|
||||
SessionID: sess.SessionID,
|
||||
AgentRequestID: agentReqID,
|
||||
ToolName: toolName,
|
||||
ToolCall: jsonOrNull(toolCall),
|
||||
Options: optionsJSON,
|
||||
Status: PermissionPending,
|
||||
})
|
||||
if err != nil {
|
||||
// 落库失败时无法人工审批,保守默认拒绝。
|
||||
s.log.Error("acp.permission.insert_pending", "session_id", sess.SessionID, "err", err.Error())
|
||||
return ""
|
||||
}
|
||||
|
||||
w := &waiter{ch: make(chan decision, 1), sessionID: sess.SessionID}
|
||||
s.mu.Lock()
|
||||
s.waiters[reqID] = w
|
||||
s.mu.Unlock()
|
||||
defer s.removeWaiter(reqID)
|
||||
|
||||
s.fanout(sess.SessionID, &EventEnvelope{
|
||||
Kind: "permission_request",
|
||||
Payload: mustJSON(permissionRequestPayload(row)),
|
||||
})
|
||||
|
||||
select {
|
||||
case d := <-w.ch:
|
||||
return d.optionID
|
||||
case <-time.After(s.timeout):
|
||||
// 超时 → 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, ""))
|
||||
}
|
||||
return ""
|
||||
case <-ctx.Done():
|
||||
// relay/进程关闭:CancelSession 已处理 DB;这里仅退出,agent 收默认拒绝。
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve 落地一次人工决策(approve/deny)。鉴权:session owner 或 admin。
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
sessRow, err := s.repo.GetSessionByID(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.IsAdmin && sessRow.UserID != c.UserID {
|
||||
return errs.New(errs.CodeAcpSessionNotOwned, "无权处理该会话的权限请求")
|
||||
}
|
||||
|
||||
var options []handlers.PermOption
|
||||
_ = json.Unmarshal(req.Options, &options)
|
||||
|
||||
var chosen string
|
||||
var status PermissionStatus
|
||||
if approve {
|
||||
chosen = optionID
|
||||
if chosen == "" {
|
||||
chosen = chooseAllowOption(options)
|
||||
} else if !optionExists(options, chosen) {
|
||||
return errs.New(errs.CodeAcpPermissionInvalidOption, "无效的选项 id")
|
||||
}
|
||||
status = PermissionApproved
|
||||
} else {
|
||||
chosen = chooseRejectOption(options)
|
||||
status = PermissionDenied
|
||||
}
|
||||
|
||||
decidedBy := c.UserID
|
||||
_, ok, err := s.repo.DecidePermissionRequest(ctx, reqID, status, strPtrOrNil(chosen), &decidedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
// 已被超时/会话终止/重复决策抢先落地。
|
||||
return errs.New(errs.CodeAcpPermissionAlreadyDecided, "该权限请求已被处理")
|
||||
}
|
||||
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, errs.New(errs.CodeAcpSessionNotOwned, "无权查看该会话")
|
||||
}
|
||||
return s.repo.ListPendingPermissionRequestsBySession(ctx, sessionID)
|
||||
}
|
||||
|
||||
// CancelSession 在会话终止时把其全部 pending 请求标 expired,并唤醒阻塞的 Decide
|
||||
// goroutine 使其立即返回(agent 收默认拒绝)。supervisor.onExit 调用。
|
||||
func (s *PermissionService) CancelSession(ctx context.Context, sessionID uuid.UUID) {
|
||||
if _, err := s.repo.ExpirePendingPermissionRequestsBySession(ctx, sessionID); err != nil {
|
||||
s.log.Error("acp.permission.cancel_session", "session_id", sessionID, "err", err.Error())
|
||||
}
|
||||
s.mu.Lock()
|
||||
for reqID, w := range s.waiters {
|
||||
if w.sessionID == sessionID {
|
||||
select {
|
||||
case w.ch <- decision{optionID: ""}:
|
||||
default:
|
||||
}
|
||||
delete(s.waiters, reqID)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// ExpireStale 把超过 before 仍 pending 的请求标 expired(启动 reaper / 兜底)。
|
||||
func (s *PermissionService) ExpireStale(ctx context.Context, before time.Time) (int64, error) {
|
||||
return s.repo.ExpireStalePermissionRequests(ctx, before)
|
||||
}
|
||||
|
||||
// ===== 内部辅助 =====
|
||||
|
||||
func (s *PermissionService) removeWaiter(reqID uuid.UUID) {
|
||||
s.mu.Lock()
|
||||
delete(s.waiters, reqID)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *PermissionService) wake(reqID uuid.UUID, d decision) {
|
||||
s.mu.Lock()
|
||||
w, ok := s.waiters[reqID]
|
||||
if ok {
|
||||
delete(s.waiters, reqID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
select {
|
||||
case w.ch <- d:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PermissionService) fanout(sessionID uuid.UUID, env *EventEnvelope) {
|
||||
s.locMu.RLock()
|
||||
loc := s.loc
|
||||
s.locMu.RUnlock()
|
||||
if loc == nil {
|
||||
return
|
||||
}
|
||||
if relay := loc.GetRelay(sessionID); relay != nil {
|
||||
relay.FanoutControl(env)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PermissionService) recordDecision(ctx context.Context, actor, sessionID uuid.UUID, toolName, outcome string, decidedBy *uuid.UUID) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
}
|
||||
a := actor
|
||||
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
|
||||
UserID: &a,
|
||||
Action: "acp.permission." + outcome,
|
||||
TargetType: "acp_session",
|
||||
TargetID: sessionID.String(),
|
||||
Metadata: map[string]any{
|
||||
"tool_name": toolName,
|
||||
"decided_by": decidedBy,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func permissionRequestPayload(p *PermissionRequest) map[string]any {
|
||||
return map[string]any{
|
||||
"request_id": p.ID.String(),
|
||||
"session_id": p.SessionID.String(),
|
||||
"tool_name": p.ToolName,
|
||||
"tool_call": json.RawMessage(p.ToolCall),
|
||||
"options": json.RawMessage(p.Options),
|
||||
"status": string(p.Status),
|
||||
"created_at": p.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedEnvelope(reqID uuid.UUID, status PermissionStatus, chosen string) *EventEnvelope {
|
||||
return &EventEnvelope{
|
||||
Kind: "permission_resolved",
|
||||
Payload: mustJSON(map[string]any{
|
||||
"request_id": reqID.String(),
|
||||
"status": string(status),
|
||||
"chosen_option_id": chosen,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// extractToolName 尝试从 toolCall JSON 中提取工具标识。多个 agent 实现的字段名不同,
|
||||
// 依次尝试 name/toolName/kind/title;都取不到则返回空(保守走人工审批)。
|
||||
func extractToolName(toolCall json.RawMessage) string {
|
||||
if len(toolCall) == 0 {
|
||||
return ""
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(toolCall, &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, k := range []string{"name", "toolName", "kind", "title"} {
|
||||
if v, ok := m[k].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// allowlistHit 判断工具是否在白名单内。含 "*" 表示全部放行;toolName 为空时不放行。
|
||||
func allowlistHit(allowlist []string, toolName string) bool {
|
||||
for _, a := range allowlist {
|
||||
if a == "*" {
|
||||
return true
|
||||
}
|
||||
if toolName != "" && a == toolName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// chooseAllowOption 选一个“批准”类选项 id:优先包含 allow/yes 的,否则取首个。
|
||||
func chooseAllowOption(options []handlers.PermOption) string {
|
||||
if id := matchOption(options, []string{"allow", "yes", "approve", "accept"}); id != "" {
|
||||
return id
|
||||
}
|
||||
if len(options) > 0 {
|
||||
return options[0].ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// chooseRejectOption 选一个“拒绝”类选项 id:优先包含 reject/deny/no 的,否则空串。
|
||||
func chooseRejectOption(options []handlers.PermOption) string {
|
||||
return matchOption(options, []string{"reject", "deny", "no"})
|
||||
}
|
||||
|
||||
func matchOption(options []handlers.PermOption, keywords []string) string {
|
||||
for _, opt := range options {
|
||||
lc := strings.ToLower(opt.ID)
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(lc, kw) {
|
||||
return opt.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func optionExists(options []handlers.PermOption, id string) bool {
|
||||
for _, opt := range options {
|
||||
if opt.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func strPtrOrNil(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func jsonOrNull(b json.RawMessage) []byte {
|
||||
if len(b) == 0 {
|
||||
return []byte("null")
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package acp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// permFakeRepo 内嵌 acp.Repository 接口(nil),仅实现 PermissionService 用到的方法;
|
||||
// 其余方法被调用会 panic(测试不应触达)。
|
||||
type permFakeRepo struct {
|
||||
acp.Repository
|
||||
mu sync.Mutex
|
||||
reqs map[uuid.UUID]*acp.PermissionRequest
|
||||
sessions map[uuid.UUID]*acp.Session
|
||||
}
|
||||
|
||||
func newPermFakeRepo() *permFakeRepo {
|
||||
return &permFakeRepo{
|
||||
reqs: map[uuid.UUID]*acp.PermissionRequest{},
|
||||
sessions: map[uuid.UUID]*acp.Session{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *permFakeRepo) InsertPermissionRequest(_ context.Context, p *acp.PermissionRequest) (*acp.PermissionRequest, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cp := *p
|
||||
cp.CreatedAt = time.Unix(0, 0)
|
||||
r.reqs[cp.ID] = &cp
|
||||
out := cp
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *permFakeRepo) GetPermissionRequestByID(_ context.Context, id uuid.UUID) (*acp.PermissionRequest, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
p, ok := r.reqs[id]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeAcpPermissionNotFound, "not found")
|
||||
}
|
||||
out := *p
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *permFakeRepo) GetSessionByID(_ context.Context, id uuid.UUID) (*acp.Session, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.sessions[id]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeAcpSessionNotFound, "not found")
|
||||
}
|
||||
out := *s
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *permFakeRepo) DecidePermissionRequest(_ context.Context, id uuid.UUID, status acp.PermissionStatus, chosen *string, decidedBy *uuid.UUID) (*acp.PermissionRequest, bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
p, ok := r.reqs[id]
|
||||
if !ok || p.Status != acp.PermissionPending {
|
||||
return nil, false, nil // CAS 失败:已非 pending
|
||||
}
|
||||
p.Status = status
|
||||
p.ChosenOptionID = chosen
|
||||
p.DecidedBy = decidedBy
|
||||
out := *p
|
||||
return &out, true, nil
|
||||
}
|
||||
|
||||
func (r *permFakeRepo) ExpirePendingPermissionRequestsBySession(_ context.Context, sid uuid.UUID) (int64, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var n int64
|
||||
for _, p := range r.reqs {
|
||||
if p.SessionID == sid && p.Status == acp.PermissionPending {
|
||||
p.Status = acp.PermissionExpired
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *permFakeRepo) statusOf(id uuid.UUID) acp.PermissionStatus {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.reqs[id].Status
|
||||
}
|
||||
|
||||
func (r *permFakeRepo) onlyReqID() uuid.UUID {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for id := range r.reqs {
|
||||
return id
|
||||
}
|
||||
return uuid.Nil
|
||||
}
|
||||
|
||||
func optionsAllowReject() []handlers.PermOption {
|
||||
return []handlers.PermOption{{ID: "allow_once"}, {ID: "reject_once"}}
|
||||
}
|
||||
|
||||
func TestPermission_AutoAllow_WhenAllowlisted(t *testing.T) {
|
||||
repo := newPermFakeRepo()
|
||||
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
|
||||
sess := handlers.SessionContext{
|
||||
SessionID: uuid.New(),
|
||||
UserID: uuid.New(),
|
||||
ToolAllowlist: []string{"safe.tool"},
|
||||
}
|
||||
chosen := svc.Decide(context.Background(), sess, "req-1",
|
||||
[]byte(`{"name":"safe.tool"}`), optionsAllowReject())
|
||||
require.Equal(t, "allow_once", chosen)
|
||||
require.Equal(t, acp.PermissionAuto, repo.statusOf(repo.onlyReqID()))
|
||||
}
|
||||
|
||||
func TestPermission_Pending_ThenApprove(t *testing.T) {
|
||||
repo := newPermFakeRepo()
|
||||
uid := uuid.New()
|
||||
sid := uuid.New()
|
||||
repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid}
|
||||
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
|
||||
sess := handlers.SessionContext{SessionID: sid, UserID: uid} // 无白名单 → 挂起
|
||||
|
||||
resCh := make(chan string, 1)
|
||||
go func() {
|
||||
resCh <- svc.Decide(context.Background(), sess, "req-1",
|
||||
[]byte(`{"name":"danger.tool"}`), optionsAllowReject())
|
||||
}()
|
||||
|
||||
// 等待 pending 落库
|
||||
var reqID uuid.UUID
|
||||
require.Eventually(t, func() bool {
|
||||
reqID = repo.onlyReqID()
|
||||
return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending
|
||||
}, time.Second, 5*time.Millisecond)
|
||||
|
||||
require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once"))
|
||||
select {
|
||||
case chosen := <-resCh:
|
||||
require.Equal(t, "allow_once", chosen)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Decide 未在批准后返回")
|
||||
}
|
||||
require.Equal(t, acp.PermissionApproved, repo.statusOf(reqID))
|
||||
}
|
||||
|
||||
func TestPermission_Pending_ThenDeny(t *testing.T) {
|
||||
repo := newPermFakeRepo()
|
||||
uid := uuid.New()
|
||||
sid := uuid.New()
|
||||
repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid}
|
||||
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
|
||||
sess := handlers.SessionContext{SessionID: sid, UserID: uid}
|
||||
|
||||
resCh := make(chan string, 1)
|
||||
go func() {
|
||||
resCh <- svc.Decide(context.Background(), sess, "req-1",
|
||||
[]byte(`{"name":"danger.tool"}`), optionsAllowReject())
|
||||
}()
|
||||
var reqID uuid.UUID
|
||||
require.Eventually(t, func() bool {
|
||||
reqID = repo.onlyReqID()
|
||||
return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending
|
||||
}, time.Second, 5*time.Millisecond)
|
||||
|
||||
require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, false, ""))
|
||||
require.Equal(t, "reject_once", <-resCh)
|
||||
require.Equal(t, acp.PermissionDenied, repo.statusOf(reqID))
|
||||
}
|
||||
|
||||
func TestPermission_Timeout_DefaultsToReject(t *testing.T) {
|
||||
repo := newPermFakeRepo()
|
||||
uid := uuid.New()
|
||||
sid := uuid.New()
|
||||
repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid}
|
||||
svc := acp.NewPermissionService(repo, nil, 30*time.Millisecond, nil)
|
||||
sess := handlers.SessionContext{SessionID: sid, UserID: uid}
|
||||
|
||||
chosen := svc.Decide(context.Background(), sess, "req-1",
|
||||
[]byte(`{"name":"danger.tool"}`), optionsAllowReject())
|
||||
require.Equal(t, "", chosen) // 默认拒绝
|
||||
require.Equal(t, acp.PermissionExpired, repo.statusOf(repo.onlyReqID()))
|
||||
}
|
||||
|
||||
func TestPermission_DuplicateDecide_Conflict(t *testing.T) {
|
||||
repo := newPermFakeRepo()
|
||||
uid := uuid.New()
|
||||
sid := uuid.New()
|
||||
repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid}
|
||||
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
|
||||
sess := handlers.SessionContext{SessionID: sid, UserID: uid}
|
||||
|
||||
go svc.Decide(context.Background(), sess, "req-1",
|
||||
[]byte(`{"name":"danger.tool"}`), optionsAllowReject())
|
||||
var reqID uuid.UUID
|
||||
require.Eventually(t, func() bool {
|
||||
reqID = repo.onlyReqID()
|
||||
return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending
|
||||
}, time.Second, 5*time.Millisecond)
|
||||
|
||||
require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once"))
|
||||
err := svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once")
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeAcpPermissionAlreadyDecided, ae.Code)
|
||||
}
|
||||
|
||||
func TestPermission_Resolve_NotOwner_Forbidden(t *testing.T) {
|
||||
repo := newPermFakeRepo()
|
||||
owner := uuid.New()
|
||||
sid := uuid.New()
|
||||
repo.sessions[sid] = &acp.Session{ID: sid, UserID: owner}
|
||||
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
|
||||
sess := handlers.SessionContext{SessionID: sid, UserID: owner}
|
||||
|
||||
go svc.Decide(context.Background(), sess, "req-1",
|
||||
[]byte(`{"name":"danger.tool"}`), optionsAllowReject())
|
||||
var reqID uuid.UUID
|
||||
require.Eventually(t, func() bool {
|
||||
reqID = repo.onlyReqID()
|
||||
return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending
|
||||
}, time.Second, 5*time.Millisecond)
|
||||
|
||||
err := svc.Resolve(context.Background(), acp.Caller{UserID: uuid.New()}, reqID, true, "allow_once")
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeAcpSessionNotOwned, ae.Code)
|
||||
// admin 可处理任意会话
|
||||
require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uuid.New(), IsAdmin: true}, reqID, true, "allow_once"))
|
||||
}
|
||||
|
||||
func TestPermission_CancelSession_UnblocksDecide(t *testing.T) {
|
||||
repo := newPermFakeRepo()
|
||||
uid := uuid.New()
|
||||
sid := uuid.New()
|
||||
repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid}
|
||||
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
|
||||
sess := handlers.SessionContext{SessionID: sid, UserID: uid}
|
||||
|
||||
resCh := make(chan string, 1)
|
||||
go func() {
|
||||
resCh <- svc.Decide(context.Background(), sess, "req-1",
|
||||
[]byte(`{"name":"danger.tool"}`), optionsAllowReject())
|
||||
}()
|
||||
require.Eventually(t, func() bool {
|
||||
id := repo.onlyReqID()
|
||||
return id != uuid.Nil && repo.statusOf(id) == acp.PermissionPending
|
||||
}, time.Second, 5*time.Millisecond)
|
||||
|
||||
svc.CancelSession(context.Background(), sid)
|
||||
select {
|
||||
case chosen := <-resCh:
|
||||
require.Equal(t, "", chosen)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("CancelSession 未解除 Decide 阻塞")
|
||||
}
|
||||
require.Equal(t, acp.PermissionExpired, repo.statusOf(repo.onlyReqID()))
|
||||
}
|
||||
@@ -1,47 +1,48 @@
|
||||
-- name: CreateAgentKind :one
|
||||
INSERT INTO acp_agent_kinds (
|
||||
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at;
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist;
|
||||
|
||||
-- name: GetAgentKindByID :one
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
FROM acp_agent_kinds
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetAgentKindByName :one
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
FROM acp_agent_kinds
|
||||
WHERE name = $1;
|
||||
|
||||
-- name: ListAgentKinds :many
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
FROM acp_agent_kinds
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: ListEnabledAgentKinds :many
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
FROM acp_agent_kinds
|
||||
WHERE enabled = TRUE
|
||||
ORDER BY name ASC;
|
||||
|
||||
-- name: UpdateAgentKind :one
|
||||
UPDATE acp_agent_kinds
|
||||
SET display_name = $2,
|
||||
description = $3,
|
||||
binary_path = $4,
|
||||
args = $5,
|
||||
encrypted_env = $6,
|
||||
enabled = $7,
|
||||
updated_at = now()
|
||||
SET display_name = $2,
|
||||
description = $3,
|
||||
binary_path = $4,
|
||||
args = $5,
|
||||
encrypted_env = $6,
|
||||
enabled = $7,
|
||||
tool_allowlist = $8,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at;
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist;
|
||||
|
||||
-- name: DeleteAgentKind :exec
|
||||
DELETE FROM acp_agent_kinds WHERE id = $1;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- name: InsertPermissionRequest :one
|
||||
INSERT INTO acp_permission_requests (
|
||||
id, session_id, agent_request_id, tool_name, tool_call, options, status, chosen_option_id, decided_by, decided_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, session_id, agent_request_id, tool_name, tool_call, options, status,
|
||||
chosen_option_id, decided_by, decided_at, created_at;
|
||||
|
||||
-- name: GetPermissionRequestByID :one
|
||||
SELECT id, session_id, agent_request_id, tool_name, tool_call, options, status,
|
||||
chosen_option_id, decided_by, decided_at, created_at
|
||||
FROM acp_permission_requests
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ListPendingPermissionRequestsBySession :many
|
||||
SELECT id, session_id, agent_request_id, tool_name, tool_call, options, status,
|
||||
chosen_option_id, decided_by, decided_at, created_at
|
||||
FROM acp_permission_requests
|
||||
WHERE session_id = $1 AND status = 'pending'
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: ListPendingPermissionRequestsByUser :many
|
||||
SELECT p.id, p.session_id, p.agent_request_id, p.tool_name, p.tool_call, p.options, p.status,
|
||||
p.chosen_option_id, p.decided_by, p.decided_at, p.created_at
|
||||
FROM acp_permission_requests p
|
||||
JOIN acp_sessions s ON s.id = p.session_id
|
||||
WHERE s.user_id = $1 AND p.status = 'pending'
|
||||
ORDER BY p.created_at ASC;
|
||||
|
||||
-- name: DecidePermissionRequest :one
|
||||
UPDATE acp_permission_requests
|
||||
SET status = $2, chosen_option_id = $3, decided_by = $4, decided_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING id, session_id, agent_request_id, tool_name, tool_call, options, status,
|
||||
chosen_option_id, decided_by, decided_at, created_at;
|
||||
|
||||
-- name: ExpirePendingPermissionRequestsBySession :execrows
|
||||
UPDATE acp_permission_requests
|
||||
SET status = 'expired', decided_at = now()
|
||||
WHERE session_id = $1 AND status = 'pending';
|
||||
|
||||
-- name: ExpireStalePermissionRequests :execrows
|
||||
UPDATE acp_permission_requests
|
||||
SET status = 'expired', decided_at = now()
|
||||
WHERE status = 'pending' AND created_at < $1;
|
||||
+13
-3
@@ -29,8 +29,8 @@ import (
|
||||
// EventEnvelope 是推到 WS 的事件结构(与落库的 Event 字段同步,但带 ID 数值化方便 JSON)。
|
||||
type EventEnvelope struct {
|
||||
ID int64 `json:"id"`
|
||||
Direction string `json:"direction"` // 'in' | 'out'
|
||||
Kind string `json:"kind"` // 'request'|'response'|'notification'|'error'|'session_terminated'|'slow_consumer_disconnect'
|
||||
Direction string `json:"direction"` // 'in' | 'out'
|
||||
Kind string `json:"kind"` // 'request'|'response'|'notification'|'error'|'session_terminated'|'slow_consumer_disconnect'
|
||||
Method string `json:"method,omitempty"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Truncated bool `json:"truncated"`
|
||||
@@ -182,6 +182,16 @@ func (r *Relay) Close(status string, exitCode *int32) {
|
||||
close(r.done)
|
||||
}
|
||||
|
||||
// FanoutControl 把一条控制类 envelope(如 permission_request/permission_resolved)
|
||||
// 推给所有 WS 订阅者。这类帧不落 acp_events、ID 恒为 0,由 PermissionService 调用。
|
||||
// relay 已关闭时静默丢弃,避免向已关 channel 写入。
|
||||
func (r *Relay) FanoutControl(env *EventEnvelope) {
|
||||
if r.closed.Load() {
|
||||
return
|
||||
}
|
||||
r.fanout(env)
|
||||
}
|
||||
|
||||
// Done returns a channel closed when Relay is terminated.
|
||||
func (r *Relay) Done() <-chan struct{} { return r.done }
|
||||
|
||||
@@ -249,7 +259,7 @@ func (r *Relay) handleAgentRequest(ctx context.Context, sess handlers.SessionCon
|
||||
out := r.fsHandler.Write.Handle(ctx, sess, req.Params)
|
||||
resp = r.fsResultToResponse(req.ID, out)
|
||||
case "session/request_permission":
|
||||
out := r.permHandler.Handle(ctx, sess, req.Params)
|
||||
out := r.permHandler.Handle(ctx, sess, string(req.ID), req.Params)
|
||||
resp = r.fsResultToResponse(req.ID, out)
|
||||
default:
|
||||
resp = NewResponseErr(req.ID, JSONRPCMethodNotFound, "method not implemented: "+req.Method)
|
||||
|
||||
@@ -71,7 +71,7 @@ func (f *fakeEventRepo) ListEnabledAgentKinds(context.Context) ([]*acp.AgentKind
|
||||
func (f *fakeEventRepo) UpdateAgentKind(context.Context, *acp.AgentKind) (*acp.AgentKind, error) {
|
||||
panic("n/a")
|
||||
}
|
||||
func (f *fakeEventRepo) DeleteAgentKind(context.Context, uuid.UUID) error { panic("n/a") }
|
||||
func (f *fakeEventRepo) DeleteAgentKind(context.Context, uuid.UUID) error { panic("n/a") }
|
||||
func (f *fakeEventRepo) CountAgentKindUsage(context.Context, uuid.UUID) (int64, error) {
|
||||
panic("n/a")
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func newRelayTestRig(t *testing.T) *relayTestRig {
|
||||
|
||||
repo := &fakeEventRepo{}
|
||||
fs := handlers.NewFsHandler()
|
||||
perm := handlers.NewPermissionHandler()
|
||||
perm := handlers.NewPermissionHandler(nil)
|
||||
|
||||
cwd := t.TempDir()
|
||||
sess := handlers.SessionContext{
|
||||
@@ -435,3 +435,26 @@ drain:
|
||||
assert.True(t, sawSlowDisc || sawClose,
|
||||
"expected slow_consumer_disconnect envelope or closed channel after flood")
|
||||
}
|
||||
|
||||
// ===== PermissionRequest(权限审批框架,测试桩) =====
|
||||
func (f *fakeEventRepo) InsertPermissionRequest(context.Context, *acp.PermissionRequest) (*acp.PermissionRequest, error) {
|
||||
return &acp.PermissionRequest{}, nil
|
||||
}
|
||||
func (f *fakeEventRepo) GetPermissionRequestByID(context.Context, uuid.UUID) (*acp.PermissionRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeEventRepo) ListPendingPermissionRequestsBySession(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeEventRepo) ListPendingPermissionRequestsByUser(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeEventRepo) DecidePermissionRequest(context.Context, uuid.UUID, acp.PermissionStatus, *string, *uuid.UUID) (*acp.PermissionRequest, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
func (f *fakeEventRepo) ExpirePendingPermissionRequestsBySession(context.Context, uuid.UUID) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeEventRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
+164
-27
@@ -48,6 +48,15 @@ type Repository interface {
|
||||
ListEventsSince(ctx context.Context, sessionID uuid.UUID, sinceID int64, limit int32) ([]*Event, error)
|
||||
PurgeEventsBefore(ctx context.Context, before time.Time) (int64, error)
|
||||
|
||||
// PermissionRequest
|
||||
InsertPermissionRequest(ctx context.Context, p *PermissionRequest) (*PermissionRequest, error)
|
||||
GetPermissionRequestByID(ctx context.Context, id uuid.UUID) (*PermissionRequest, error)
|
||||
ListPendingPermissionRequestsBySession(ctx context.Context, sessionID uuid.UUID) ([]*PermissionRequest, error)
|
||||
ListPendingPermissionRequestsByUser(ctx context.Context, userID uuid.UUID) ([]*PermissionRequest, error)
|
||||
DecidePermissionRequest(ctx context.Context, id uuid.UUID, status PermissionStatus, chosenOptionID *string, decidedBy *uuid.UUID) (*PermissionRequest, bool, error)
|
||||
ExpirePendingPermissionRequestsBySession(ctx context.Context, sessionID uuid.UUID) (int64, error)
|
||||
ExpireStalePermissionRequests(ctx context.Context, before time.Time) (int64, error)
|
||||
|
||||
InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error
|
||||
WithTx(tx pgx.Tx) Repository
|
||||
}
|
||||
@@ -129,15 +138,16 @@ func pgxNoRowsToErrNotFound(err error, code errs.Code, msg string) error {
|
||||
|
||||
func (r *pgRepo) CreateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) {
|
||||
row, err := r.q.CreateAgentKind(ctx, acpsqlc.CreateAgentKindParams{
|
||||
ID: toPgUUID(k.ID),
|
||||
Name: k.Name,
|
||||
DisplayName: k.DisplayName,
|
||||
Description: k.Description,
|
||||
BinaryPath: k.BinaryPath,
|
||||
Args: k.Args,
|
||||
EncryptedEnv: k.EncryptedEnv,
|
||||
Enabled: k.Enabled,
|
||||
CreatedBy: toPgUUID(k.CreatedBy),
|
||||
ID: toPgUUID(k.ID),
|
||||
Name: k.Name,
|
||||
DisplayName: k.DisplayName,
|
||||
Description: k.Description,
|
||||
BinaryPath: k.BinaryPath,
|
||||
Args: k.Args,
|
||||
EncryptedEnv: k.EncryptedEnv,
|
||||
Enabled: k.Enabled,
|
||||
CreatedBy: toPgUUID(k.CreatedBy),
|
||||
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "create agent kind")
|
||||
@@ -187,13 +197,14 @@ func (r *pgRepo) ListEnabledAgentKinds(ctx context.Context) ([]*AgentKind, error
|
||||
|
||||
func (r *pgRepo) UpdateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) {
|
||||
row, err := r.q.UpdateAgentKind(ctx, acpsqlc.UpdateAgentKindParams{
|
||||
ID: toPgUUID(k.ID),
|
||||
DisplayName: k.DisplayName,
|
||||
Description: k.Description,
|
||||
BinaryPath: k.BinaryPath,
|
||||
Args: k.Args,
|
||||
EncryptedEnv: k.EncryptedEnv,
|
||||
Enabled: k.Enabled,
|
||||
ID: toPgUUID(k.ID),
|
||||
DisplayName: k.DisplayName,
|
||||
Description: k.Description,
|
||||
BinaryPath: k.BinaryPath,
|
||||
Args: k.Args,
|
||||
EncryptedEnv: k.EncryptedEnv,
|
||||
Enabled: k.Enabled,
|
||||
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpAgentKindNotFound, "agent kind not found")
|
||||
@@ -223,20 +234,30 @@ func (r *pgRepo) CountAgentKindUsage(ctx context.Context, id uuid.UUID) (int64,
|
||||
|
||||
func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind {
|
||||
return &AgentKind{
|
||||
ID: fromPgUUID(row.ID),
|
||||
Name: row.Name,
|
||||
DisplayName: row.DisplayName,
|
||||
Description: row.Description,
|
||||
BinaryPath: row.BinaryPath,
|
||||
Args: row.Args,
|
||||
EncryptedEnv: row.EncryptedEnv,
|
||||
Enabled: row.Enabled,
|
||||
CreatedBy: fromPgUUID(row.CreatedBy),
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
ID: fromPgUUID(row.ID),
|
||||
Name: row.Name,
|
||||
DisplayName: row.DisplayName,
|
||||
Description: row.Description,
|
||||
BinaryPath: row.BinaryPath,
|
||||
Args: row.Args,
|
||||
EncryptedEnv: row.EncryptedEnv,
|
||||
Enabled: row.Enabled,
|
||||
ToolAllowlist: row.ToolAllowlist,
|
||||
CreatedBy: fromPgUUID(row.CreatedBy),
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeStrSlice 把 nil 切片归一为非 nil 空切片,避免写入 TEXT[] NOT NULL 列时
|
||||
// 产生 NULL(pg 驱动对 nil []string 写 NULL,违反 NOT NULL 约束)。
|
||||
func normalizeStrSlice(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ===== Session =====
|
||||
|
||||
func (r *pgRepo) InsertSession(ctx context.Context, s *Session) (*Session, error) {
|
||||
@@ -454,3 +475,119 @@ func rowToEvent(row acpsqlc.AcpEvent) *Event {
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PermissionRequest =====
|
||||
|
||||
func (r *pgRepo) InsertPermissionRequest(ctx context.Context, p *PermissionRequest) (*PermissionRequest, error) {
|
||||
row, err := r.q.InsertPermissionRequest(ctx, acpsqlc.InsertPermissionRequestParams{
|
||||
ID: toPgUUID(p.ID),
|
||||
SessionID: toPgUUID(p.SessionID),
|
||||
AgentRequestID: p.AgentRequestID,
|
||||
ToolName: p.ToolName,
|
||||
ToolCall: p.ToolCall,
|
||||
Options: p.Options,
|
||||
Status: string(p.Status),
|
||||
ChosenOptionID: p.ChosenOptionID,
|
||||
DecidedBy: toPgUUIDPtr(p.DecidedBy),
|
||||
DecidedAt: timePtrToPg(p.DecidedAt),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "insert permission request")
|
||||
}
|
||||
return rowToPermissionRequest(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetPermissionRequestByID(ctx context.Context, id uuid.UUID) (*PermissionRequest, error) {
|
||||
row, err := r.q.GetPermissionRequestByID(ctx, toPgUUID(id))
|
||||
if err != nil {
|
||||
return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpPermissionNotFound, "permission request not found")
|
||||
}
|
||||
return rowToPermissionRequest(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListPendingPermissionRequestsBySession(ctx context.Context, sessionID uuid.UUID) ([]*PermissionRequest, error) {
|
||||
rows, err := r.q.ListPendingPermissionRequestsBySession(ctx, toPgUUID(sessionID))
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list pending permission requests by session")
|
||||
}
|
||||
out := make([]*PermissionRequest, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToPermissionRequest(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListPendingPermissionRequestsByUser(ctx context.Context, userID uuid.UUID) ([]*PermissionRequest, error) {
|
||||
rows, err := r.q.ListPendingPermissionRequestsByUser(ctx, toPgUUID(userID))
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list pending permission requests by user")
|
||||
}
|
||||
out := make([]*PermissionRequest, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToPermissionRequest(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecidePermissionRequest 用 CAS(WHERE status='pending')落地决策。第二个返回值
|
||||
// ok=false 表示该请求已非 pending(重复决策/已超时),调用方据此判 409 或忽略。
|
||||
func (r *pgRepo) DecidePermissionRequest(ctx context.Context, id uuid.UUID, status PermissionStatus, chosenOptionID *string, decidedBy *uuid.UUID) (*PermissionRequest, bool, error) {
|
||||
row, err := r.q.DecidePermissionRequest(ctx, acpsqlc.DecidePermissionRequestParams{
|
||||
ID: toPgUUID(id),
|
||||
Status: string(status),
|
||||
ChosenOptionID: chosenOptionID,
|
||||
DecidedBy: toPgUUIDPtr(decidedBy),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, errs.Wrap(err, errs.CodeInternal, "decide permission request")
|
||||
}
|
||||
return rowToPermissionRequest(row), true, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ExpirePendingPermissionRequestsBySession(ctx context.Context, sessionID uuid.UUID) (int64, error) {
|
||||
n, err := r.q.ExpirePendingPermissionRequestsBySession(ctx, toPgUUID(sessionID))
|
||||
if err != nil {
|
||||
return 0, errs.Wrap(err, errs.CodeInternal, "expire pending permission requests by session")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ExpireStalePermissionRequests(ctx context.Context, before time.Time) (int64, error) {
|
||||
n, err := r.q.ExpireStalePermissionRequests(ctx, pgtype.Timestamptz{Time: before, Valid: true})
|
||||
if err != nil {
|
||||
return 0, errs.Wrap(err, errs.CodeInternal, "expire stale permission requests")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// timePtrToPg 把 *time.Time 转为 pgtype.Timestamptz(nil → Valid=false)。
|
||||
func timePtrToPg(t *time.Time) pgtype.Timestamptz {
|
||||
if t == nil {
|
||||
return pgtype.Timestamptz{}
|
||||
}
|
||||
return pgtype.Timestamptz{Time: *t, Valid: true}
|
||||
}
|
||||
|
||||
func rowToPermissionRequest(row acpsqlc.AcpPermissionRequest) *PermissionRequest {
|
||||
var decidedAt *time.Time
|
||||
if row.DecidedAt.Valid {
|
||||
t := row.DecidedAt.Time
|
||||
decidedAt = &t
|
||||
}
|
||||
return &PermissionRequest{
|
||||
ID: fromPgUUID(row.ID),
|
||||
SessionID: fromPgUUID(row.SessionID),
|
||||
AgentRequestID: row.AgentRequestID,
|
||||
ToolName: row.ToolName,
|
||||
ToolCall: row.ToolCall,
|
||||
Options: row.Options,
|
||||
Status: PermissionStatus(row.Status),
|
||||
ChosenOptionID: row.ChosenOptionID,
|
||||
DecidedBy: fromPgUUIDPtr(row.DecidedBy),
|
||||
DecidedAt: decidedAt,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,15 +201,17 @@ func (r *fakeAcpRepo) CreateAgentKind(_ context.Context, k *acp.AgentKind) (*acp
|
||||
func (r *fakeAcpRepo) GetAgentKindByName(_ context.Context, _ string) (*acp.AgentKind, error) {
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
func (r *fakeAcpRepo) ListAgentKinds(_ context.Context) ([]*acp.AgentKind, error) { return nil, nil }
|
||||
func (r *fakeAcpRepo) ListAgentKinds(_ context.Context) ([]*acp.AgentKind, error) { return nil, nil }
|
||||
func (r *fakeAcpRepo) ListEnabledAgentKinds(_ context.Context) ([]*acp.AgentKind, error) {
|
||||
return r.kinds, nil
|
||||
}
|
||||
func (r *fakeAcpRepo) UpdateAgentKind(_ context.Context, k *acp.AgentKind) (*acp.AgentKind, error) {
|
||||
return k, nil
|
||||
}
|
||||
func (r *fakeAcpRepo) DeleteAgentKind(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
func (r *fakeAcpRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) { return 0, nil }
|
||||
func (r *fakeAcpRepo) DeleteAgentKind(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
func (r *fakeAcpRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (r *fakeAcpRepo) ListSessionsByUser(_ context.Context, _ uuid.UUID) ([]*acp.Session, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -415,3 +417,26 @@ func TestSessionService_Create_TokenIssueFails_RollsBack(t *testing.T) {
|
||||
assert.Empty(t, repo.sessions)
|
||||
repo.mu.Unlock()
|
||||
}
|
||||
|
||||
// ===== PermissionRequest(权限审批框架,测试桩) =====
|
||||
func (f *fakeAcpRepo) InsertPermissionRequest(context.Context, *acp.PermissionRequest) (*acp.PermissionRequest, error) {
|
||||
return &acp.PermissionRequest{}, nil
|
||||
}
|
||||
func (f *fakeAcpRepo) GetPermissionRequestByID(context.Context, uuid.UUID) (*acp.PermissionRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAcpRepo) ListPendingPermissionRequestsBySession(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAcpRepo) ListPendingPermissionRequestsByUser(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAcpRepo) DecidePermissionRequest(context.Context, uuid.UUID, acp.PermissionStatus, *string, *uuid.UUID) (*acp.PermissionRequest, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
func (f *fakeAcpRepo) ExpirePendingPermissionRequestsBySession(context.Context, uuid.UUID) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeAcpRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -24,22 +24,23 @@ func (q *Queries) CountAgentKindUsage(ctx context.Context, agentKindID pgtype.UU
|
||||
|
||||
const createAgentKind = `-- name: CreateAgentKind :one
|
||||
INSERT INTO acp_agent_kinds (
|
||||
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
`
|
||||
|
||||
type CreateAgentKindParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams) (AcpAgentKind, error) {
|
||||
@@ -53,6 +54,7 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams
|
||||
arg.EncryptedEnv,
|
||||
arg.Enabled,
|
||||
arg.CreatedBy,
|
||||
arg.ToolAllowlist,
|
||||
)
|
||||
var i AcpAgentKind
|
||||
err := row.Scan(
|
||||
@@ -67,6 +69,7 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -82,7 +85,7 @@ func (q *Queries) DeleteAgentKind(ctx context.Context, id pgtype.UUID) error {
|
||||
|
||||
const getAgentKindByID = `-- name: GetAgentKindByID :one
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
FROM acp_agent_kinds
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -102,13 +105,14 @@ func (q *Queries) GetAgentKindByID(ctx context.Context, id pgtype.UUID) (AcpAgen
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAgentKindByName = `-- name: GetAgentKindByName :one
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
FROM acp_agent_kinds
|
||||
WHERE name = $1
|
||||
`
|
||||
@@ -128,13 +132,14 @@ func (q *Queries) GetAgentKindByName(ctx context.Context, name string) (AcpAgent
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAgentKinds = `-- name: ListAgentKinds :many
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
FROM acp_agent_kinds
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
@@ -160,6 +165,7 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -173,7 +179,7 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
|
||||
|
||||
const listEnabledAgentKinds = `-- name: ListEnabledAgentKinds :many
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
FROM acp_agent_kinds
|
||||
WHERE enabled = TRUE
|
||||
ORDER BY name ASC
|
||||
@@ -200,6 +206,7 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -213,26 +220,28 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er
|
||||
|
||||
const updateAgentKind = `-- name: UpdateAgentKind :one
|
||||
UPDATE acp_agent_kinds
|
||||
SET display_name = $2,
|
||||
description = $3,
|
||||
binary_path = $4,
|
||||
args = $5,
|
||||
encrypted_env = $6,
|
||||
enabled = $7,
|
||||
updated_at = now()
|
||||
SET display_name = $2,
|
||||
description = $3,
|
||||
binary_path = $4,
|
||||
args = $5,
|
||||
encrypted_env = $6,
|
||||
enabled = $7,
|
||||
tool_allowlist = $8,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
`
|
||||
|
||||
type UpdateAgentKindParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams) (AcpAgentKind, error) {
|
||||
@@ -244,6 +253,7 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams
|
||||
arg.Args,
|
||||
arg.EncryptedEnv,
|
||||
arg.Enabled,
|
||||
arg.ToolAllowlist,
|
||||
)
|
||||
var i AcpAgentKind
|
||||
err := row.Scan(
|
||||
@@ -258,6 +268,7 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
+27
-11
@@ -11,17 +11,18 @@ import (
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
@@ -36,6 +37,20 @@ type AcpEvent struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type AcpPermissionRequest struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
AgentRequestID string `json:"agent_request_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolCall []byte `json:"tool_call"`
|
||||
Options []byte `json:"options"`
|
||||
Status string `json:"status"`
|
||||
ChosenOptionID *string `json:"chosen_option_id"`
|
||||
DecidedBy pgtype.UUID `json:"decided_by"`
|
||||
DecidedAt pgtype.Timestamptz `json:"decided_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type AcpSession struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
@@ -269,6 +284,7 @@ type User struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type UserSession struct {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: permissions.sql
|
||||
|
||||
package acpsqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const decidePermissionRequest = `-- name: DecidePermissionRequest :one
|
||||
UPDATE acp_permission_requests
|
||||
SET status = $2, chosen_option_id = $3, decided_by = $4, decided_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING id, session_id, agent_request_id, tool_name, tool_call, options, status,
|
||||
chosen_option_id, decided_by, decided_at, created_at
|
||||
`
|
||||
|
||||
type DecidePermissionRequestParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ChosenOptionID *string `json:"chosen_option_id"`
|
||||
DecidedBy pgtype.UUID `json:"decided_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) DecidePermissionRequest(ctx context.Context, arg DecidePermissionRequestParams) (AcpPermissionRequest, error) {
|
||||
row := q.db.QueryRow(ctx, decidePermissionRequest,
|
||||
arg.ID,
|
||||
arg.Status,
|
||||
arg.ChosenOptionID,
|
||||
arg.DecidedBy,
|
||||
)
|
||||
var i AcpPermissionRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.AgentRequestID,
|
||||
&i.ToolName,
|
||||
&i.ToolCall,
|
||||
&i.Options,
|
||||
&i.Status,
|
||||
&i.ChosenOptionID,
|
||||
&i.DecidedBy,
|
||||
&i.DecidedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const expirePendingPermissionRequestsBySession = `-- name: ExpirePendingPermissionRequestsBySession :execrows
|
||||
UPDATE acp_permission_requests
|
||||
SET status = 'expired', decided_at = now()
|
||||
WHERE session_id = $1 AND status = 'pending'
|
||||
`
|
||||
|
||||
func (q *Queries) ExpirePendingPermissionRequestsBySession(ctx context.Context, sessionID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, expirePendingPermissionRequestsBySession, sessionID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const expireStalePermissionRequests = `-- name: ExpireStalePermissionRequests :execrows
|
||||
UPDATE acp_permission_requests
|
||||
SET status = 'expired', decided_at = now()
|
||||
WHERE status = 'pending' AND created_at < $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExpireStalePermissionRequests(ctx context.Context, createdAt pgtype.Timestamptz) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, expireStalePermissionRequests, createdAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const getPermissionRequestByID = `-- name: GetPermissionRequestByID :one
|
||||
SELECT id, session_id, agent_request_id, tool_name, tool_call, options, status,
|
||||
chosen_option_id, decided_by, decided_at, created_at
|
||||
FROM acp_permission_requests
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetPermissionRequestByID(ctx context.Context, id pgtype.UUID) (AcpPermissionRequest, error) {
|
||||
row := q.db.QueryRow(ctx, getPermissionRequestByID, id)
|
||||
var i AcpPermissionRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.AgentRequestID,
|
||||
&i.ToolName,
|
||||
&i.ToolCall,
|
||||
&i.Options,
|
||||
&i.Status,
|
||||
&i.ChosenOptionID,
|
||||
&i.DecidedBy,
|
||||
&i.DecidedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertPermissionRequest = `-- name: InsertPermissionRequest :one
|
||||
INSERT INTO acp_permission_requests (
|
||||
id, session_id, agent_request_id, tool_name, tool_call, options, status, chosen_option_id, decided_by, decided_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, session_id, agent_request_id, tool_name, tool_call, options, status,
|
||||
chosen_option_id, decided_by, decided_at, created_at
|
||||
`
|
||||
|
||||
type InsertPermissionRequestParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
AgentRequestID string `json:"agent_request_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolCall []byte `json:"tool_call"`
|
||||
Options []byte `json:"options"`
|
||||
Status string `json:"status"`
|
||||
ChosenOptionID *string `json:"chosen_option_id"`
|
||||
DecidedBy pgtype.UUID `json:"decided_by"`
|
||||
DecidedAt pgtype.Timestamptz `json:"decided_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertPermissionRequest(ctx context.Context, arg InsertPermissionRequestParams) (AcpPermissionRequest, error) {
|
||||
row := q.db.QueryRow(ctx, insertPermissionRequest,
|
||||
arg.ID,
|
||||
arg.SessionID,
|
||||
arg.AgentRequestID,
|
||||
arg.ToolName,
|
||||
arg.ToolCall,
|
||||
arg.Options,
|
||||
arg.Status,
|
||||
arg.ChosenOptionID,
|
||||
arg.DecidedBy,
|
||||
arg.DecidedAt,
|
||||
)
|
||||
var i AcpPermissionRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.AgentRequestID,
|
||||
&i.ToolName,
|
||||
&i.ToolCall,
|
||||
&i.Options,
|
||||
&i.Status,
|
||||
&i.ChosenOptionID,
|
||||
&i.DecidedBy,
|
||||
&i.DecidedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listPendingPermissionRequestsBySession = `-- name: ListPendingPermissionRequestsBySession :many
|
||||
SELECT id, session_id, agent_request_id, tool_name, tool_call, options, status,
|
||||
chosen_option_id, decided_by, decided_at, created_at
|
||||
FROM acp_permission_requests
|
||||
WHERE session_id = $1 AND status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListPendingPermissionRequestsBySession(ctx context.Context, sessionID pgtype.UUID) ([]AcpPermissionRequest, error) {
|
||||
rows, err := q.db.Query(ctx, listPendingPermissionRequestsBySession, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []AcpPermissionRequest
|
||||
for rows.Next() {
|
||||
var i AcpPermissionRequest
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.AgentRequestID,
|
||||
&i.ToolName,
|
||||
&i.ToolCall,
|
||||
&i.Options,
|
||||
&i.Status,
|
||||
&i.ChosenOptionID,
|
||||
&i.DecidedBy,
|
||||
&i.DecidedAt,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listPendingPermissionRequestsByUser = `-- name: ListPendingPermissionRequestsByUser :many
|
||||
SELECT p.id, p.session_id, p.agent_request_id, p.tool_name, p.tool_call, p.options, p.status,
|
||||
p.chosen_option_id, p.decided_by, p.decided_at, p.created_at
|
||||
FROM acp_permission_requests p
|
||||
JOIN acp_sessions s ON s.id = p.session_id
|
||||
WHERE s.user_id = $1 AND p.status = 'pending'
|
||||
ORDER BY p.created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListPendingPermissionRequestsByUser(ctx context.Context, userID pgtype.UUID) ([]AcpPermissionRequest, error) {
|
||||
rows, err := q.db.Query(ctx, listPendingPermissionRequestsByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []AcpPermissionRequest
|
||||
for rows.Next() {
|
||||
var i AcpPermissionRequest
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.AgentRequestID,
|
||||
&i.ToolName,
|
||||
&i.ToolCall,
|
||||
&i.Options,
|
||||
&i.Status,
|
||||
&i.ChosenOptionID,
|
||||
&i.DecidedBy,
|
||||
&i.DecidedAt,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
+33
-16
@@ -47,17 +47,22 @@ type Supervisor struct {
|
||||
mu sync.RWMutex
|
||||
procs map[uuid.UUID]*Process
|
||||
|
||||
repo Repository
|
||||
audit audit.Recorder
|
||||
notify *notify.Dispatcher
|
||||
repo Repository
|
||||
audit audit.Recorder
|
||||
notify *notify.Dispatcher
|
||||
wtSvc workspace.WorktreeService
|
||||
wsRepo workspace.Repository
|
||||
mcpTokens mcp.TokenService // spec §6.3 — onExit revokes system token
|
||||
crypto *crypto.Encryptor
|
||||
cfg SupervisorConfig
|
||||
log *slog.Logger
|
||||
permSvc *PermissionService // 工具调用权限审批;可为 nil(部分测试)
|
||||
cfg SupervisorConfig
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// SetPermissionService 注入权限审批服务。与 Supervisor 互相依赖,故装配时回填
|
||||
// (permSvc 需要 Supervisor 作为 RelayLocator,Supervisor 需要 permSvc 注入 handler)。
|
||||
func (s *Supervisor) SetPermissionService(p *PermissionService) { s.permSvc = p }
|
||||
|
||||
// NewSupervisor 构造空 Supervisor。Start 不需要 — supervisor 是被动的(spawn 时
|
||||
// 起 goroutine,无主循环)。Stop 在 ShutdownAll 中实现。
|
||||
func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher,
|
||||
@@ -68,16 +73,16 @@ func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher,
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Supervisor{
|
||||
procs: map[uuid.UUID]*Process{},
|
||||
repo: repo,
|
||||
audit: rec,
|
||||
notify: disp,
|
||||
wtSvc: wtSvc,
|
||||
wsRepo: wsRepo,
|
||||
procs: map[uuid.UUID]*Process{},
|
||||
repo: repo,
|
||||
audit: rec,
|
||||
notify: disp,
|
||||
wtSvc: wtSvc,
|
||||
wsRepo: wsRepo,
|
||||
mcpTokens: mcpTokens,
|
||||
crypto: enc,
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
crypto: enc,
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +100,7 @@ type Process struct {
|
||||
Stderr io.ReadCloser
|
||||
Relay *Relay
|
||||
|
||||
StderrBuf *stderrRing // 排障 ring buffer
|
||||
StderrBuf *stderrRing // 排障 ring buffer
|
||||
StartedAt time.Time
|
||||
KilledByUs atomic.Bool // true → exited,false → crashed
|
||||
done chan struct{} // monitor 退出时 close
|
||||
@@ -185,13 +190,17 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
return nil, fmt.Errorf("start: %w", err)
|
||||
}
|
||||
|
||||
var decider handlers.PermissionDecider
|
||||
if s.permSvc != nil {
|
||||
decider = s.permSvc
|
||||
}
|
||||
relay := NewRelay(
|
||||
sess.ID,
|
||||
NewDecoder(stdout, s.cfg.EventMaxPayload),
|
||||
NewEncoder(stdin),
|
||||
s.repo,
|
||||
handlers.NewFsHandler(),
|
||||
handlers.NewPermissionHandler(),
|
||||
handlers.NewPermissionHandler(decider),
|
||||
RelayConfig{
|
||||
EventMaxPayload: s.cfg.EventMaxPayload,
|
||||
EventTruncateField: s.cfg.EventTruncateField,
|
||||
@@ -227,6 +236,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
UserID: sess.UserID,
|
||||
CwdPath: sess.CwdPath,
|
||||
AgentSessionID: sess.AgentSessionID,
|
||||
ToolAllowlist: kind.ToolAllowlist,
|
||||
})
|
||||
|
||||
if err := s.handshake(ctx, sess, relay, proc); err != nil {
|
||||
@@ -441,6 +451,13 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt
|
||||
}
|
||||
}
|
||||
_ = waitErr // 已通过 KilledByUs / ExitError 编码到 status / exitCode
|
||||
|
||||
// 终止会话的全部挂起权限请求:标 expired 并唤醒阻塞的 Decide goroutine,
|
||||
// 避免 agent 端永久挂起。须在 Relay.Close 之前(Close 后 FanoutControl 静默丢弃)。
|
||||
if s.permSvc != nil {
|
||||
s.permSvc.CancelSession(ctx, proc.SessionID)
|
||||
}
|
||||
|
||||
// Revoke session's system MCP tokens (spec §6.3)
|
||||
if s.mcpTokens != nil {
|
||||
if err := s.mcpTokens.RevokeBySession(ctx, proc.SessionID); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user