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 {
|
||||
|
||||
@@ -176,6 +176,9 @@ func newACPIntegrationSuite(t *testing.T) *acpIntegrationSuite {
|
||||
ShutdownGrace: 5 * time.Second,
|
||||
}
|
||||
sup := acp.NewSupervisor(acpRepo, auditRec, disp, nil, nil, nil, enc, supCfg, log)
|
||||
permSvc := acp.NewPermissionService(acpRepo, auditRec, 0, log)
|
||||
sup.SetPermissionService(permSvc)
|
||||
permSvc.SetRelayLocator(sup)
|
||||
|
||||
// Minimal stubs for SessionService dependencies — sufficient for branch=main
|
||||
// path (no wtSvc / wsRepo calls) and tests that exercise quota/conflict logic.
|
||||
@@ -192,7 +195,7 @@ func newACPIntegrationSuite(t *testing.T) *acpIntegrationSuite {
|
||||
r.Use(middleware.RequestID)
|
||||
user.NewHandler(userSvc).Mount(r)
|
||||
acp.NewHandler(
|
||||
akSvc, sessSvc, sup, acpRepo,
|
||||
akSvc, sessSvc, sup, acpRepo, permSvc,
|
||||
userSvc,
|
||||
acpUserAdminAdapter{svc: userSvc},
|
||||
enc,
|
||||
|
||||
+22
-2
@@ -32,7 +32,6 @@ import (
|
||||
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/chat"
|
||||
mcp "github.com/yan1h/agent-coding-workflow/internal/mcp"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/config"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/clock"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
||||
@@ -45,6 +44,7 @@ import (
|
||||
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/jobs/handlers"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/jobs/runners"
|
||||
mcp "github.com/yan1h/agent-coding-workflow/internal/mcp"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
@@ -300,6 +300,11 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
||||
"session_id", sess.ID, "err", rerr.Error())
|
||||
}
|
||||
}
|
||||
// 该会话已被标 crashed,其挂起的权限请求也应一并标 expired,避免悬挂。
|
||||
if _, rerr := acpRepo.ExpirePendingPermissionRequestsBySession(ctx, sess.ID); rerr != nil {
|
||||
log.Warn("acp.startup_reaper.expire_permissions_failed",
|
||||
"session_id", sess.ID, "err", rerr.Error())
|
||||
}
|
||||
uid := sess.UserID
|
||||
if rerr := auditRec.Record(ctx, audit.Entry{
|
||||
UserID: &uid, Action: "acp.session.aborted_on_restart",
|
||||
@@ -369,6 +374,11 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
||||
},
|
||||
log,
|
||||
)
|
||||
// 权限审批服务:与 supervisor 互相依赖,构造后回填(SetPermissionService /
|
||||
// SetRelayLocator),再注入 handler。
|
||||
acpPermSvc := acp.NewPermissionService(acpRepo, auditRec, cfg.Acp.PermissionTimeout, log)
|
||||
acpSup.SetPermissionService(acpPermSvc)
|
||||
acpPermSvc.SetRelayLocator(acpSup)
|
||||
sessSvc := acp.NewSessionService(
|
||||
acpRepo, wsSvc, wtSvc, wsRepo,
|
||||
acpProjAccess, acpSup, auditRec,
|
||||
@@ -380,7 +390,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
||||
},
|
||||
mcpTokenSvc,
|
||||
)
|
||||
acp.NewHandler(akSvc, sessSvc, acpSup, acpRepo, userSvc, userAdminAdapter{svc: userSvc}, enc, acp.WSConfig{
|
||||
acp.NewHandler(akSvc, sessSvc, acpSup, acpRepo, acpPermSvc, userSvc, userAdminAdapter{svc: userSvc}, enc, acp.WSConfig{
|
||||
PingInterval: cfg.Acp.WSPingInterval,
|
||||
PongTimeout: cfg.Acp.WSPongTimeout,
|
||||
SendBuffer: cfg.Acp.WSSendBuffer,
|
||||
@@ -543,6 +553,16 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 注册 system 作用域的 prompt 模板为 MCP prompts(spec)。需在 bootstrap 之后
|
||||
// 执行以保证存在 admin;失败仅 warn,不阻断启动。以第一个 admin 的视角加载模板。
|
||||
if admins, err := userSvc.ListAdmins(ctx); err != nil {
|
||||
log.Warn("mcp register_system_prompts: list admins failed", "err", err.Error())
|
||||
} else if len(admins) > 0 {
|
||||
if err := mcp.RegisterSystemPrompts(mcpServer, mcpDeps, admins[0].ID); err != nil {
|
||||
log.Warn("mcp register_system_prompts failed", "err", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTP.Addr,
|
||||
Handler: r,
|
||||
|
||||
@@ -47,9 +47,9 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
tcpg "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||
@@ -327,9 +327,9 @@ func (s *mcpIntegrationSuite) seedAdditionalProject(t *testing.T, ownerID uuid.U
|
||||
t.Helper()
|
||||
caller := project.Caller{UserID: ownerID, IsAdmin: ownerID == s.adminID}
|
||||
p, err := s.projectSvc.Create(s.ctx, caller, project.CreateProjectInput{
|
||||
Slug: slug,
|
||||
Name: slug,
|
||||
Visibility: project.VisibilityInternal,
|
||||
Slug: slug,
|
||||
Name: slug,
|
||||
Visibility: project.VisibilityInternal,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return p.ID
|
||||
@@ -342,9 +342,9 @@ func (s *mcpIntegrationSuite) seedPrivateProjectFor(t *testing.T, ownerID uuid.U
|
||||
slug := fmt.Sprintf("private-%s", uuid.New().String()[:8])
|
||||
caller := project.Caller{UserID: ownerID, IsAdmin: false}
|
||||
p, err := s.projectSvc.Create(s.ctx, caller, project.CreateProjectInput{
|
||||
Slug: slug,
|
||||
Name: "Private " + slug,
|
||||
Visibility: project.VisibilityPrivate,
|
||||
Slug: slug,
|
||||
Name: "Private " + slug,
|
||||
Visibility: project.VisibilityPrivate,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return p.ID, p.Slug
|
||||
@@ -390,8 +390,8 @@ func (s *mcpIntegrationSuite) queryAuditMetadata(t *testing.T, action string) ma
|
||||
|
||||
// bearerRoundTripper injects an Authorization: Bearer header into every request.
|
||||
type bearerRoundTripper struct {
|
||||
inner http.RoundTripper
|
||||
token string
|
||||
inner http.RoundTripper
|
||||
token string
|
||||
}
|
||||
|
||||
func (rt *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -197,14 +197,15 @@ type AcpConfig struct {
|
||||
EventTruncateField int `mapstructure:"event_truncate_field_bytes"`
|
||||
EventRetentionDays int `mapstructure:"event_retention_days"`
|
||||
ShutdownGrace time.Duration `mapstructure:"shutdown_grace"`
|
||||
PermissionTimeout time.Duration `mapstructure:"permission_timeout"`
|
||||
}
|
||||
|
||||
// MCPConfig 控制 MCP 模块的开关、对外 URL(注入 ACP 子进程 env)、system token TTL 与速率限制。
|
||||
type MCPConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
PublicURL string `mapstructure:"public_url"`
|
||||
SystemTokenTTL time.Duration `mapstructure:"system_token_ttl"`
|
||||
RateLimit MCPRateLimitCfg `mapstructure:"rate_limit"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
PublicURL string `mapstructure:"public_url"`
|
||||
SystemTokenTTL time.Duration `mapstructure:"system_token_ttl"`
|
||||
RateLimit MCPRateLimitCfg `mapstructure:"rate_limit"`
|
||||
}
|
||||
|
||||
// MCPRateLimitCfg 控制 token bucket 容量。两层独立桶:per-token + 全局。
|
||||
@@ -278,6 +279,7 @@ func Load(configFile string) (*Config, error) {
|
||||
v.SetDefault("acp.event_truncate_field_bytes", 4096)
|
||||
v.SetDefault("acp.event_retention_days", 7)
|
||||
v.SetDefault("acp.shutdown_grace", "30s")
|
||||
v.SetDefault("acp.permission_timeout", "5m")
|
||||
v.SetDefault("mcp.enabled", true)
|
||||
v.SetDefault("mcp.public_url", "http://localhost:8080/mcp")
|
||||
v.SetDefault("mcp.system_token_ttl", "24h")
|
||||
|
||||
+28
-19
@@ -75,27 +75,30 @@ const (
|
||||
CodeWebhookNotConfigured Code = "webhook.not_configured"
|
||||
|
||||
// ACP 域
|
||||
CodeAcpAgentKindNotFound Code = "acp.agent_kind_not_found"
|
||||
CodeAcpAgentKindDisabled Code = "acp.agent_kind_disabled"
|
||||
CodeAcpAgentKindInUse Code = "acp.agent_kind_in_use"
|
||||
CodeAcpSessionNotFound Code = "acp.session_not_found"
|
||||
CodeAcpSessionQuotaExceeded Code = "acp.session_quota_exceeded"
|
||||
CodeAcpSessionNotOwned Code = "acp.session_not_owned"
|
||||
CodeAcpSessionTerminated Code = "acp.session_terminated"
|
||||
CodeAcpSessionBranchConflict Code = "acp.session_branch_conflict"
|
||||
CodeAcpSpawnFailed Code = "acp.spawn_failed"
|
||||
CodeAcpHandshakeTimeout Code = "acp.handshake_timeout"
|
||||
CodeAcpFsPathOutsideWorktree Code = "acp.fs_path_outside_worktree"
|
||||
CodeAcpAgentKindNotFound Code = "acp.agent_kind_not_found"
|
||||
CodeAcpAgentKindDisabled Code = "acp.agent_kind_disabled"
|
||||
CodeAcpAgentKindInUse Code = "acp.agent_kind_in_use"
|
||||
CodeAcpSessionNotFound Code = "acp.session_not_found"
|
||||
CodeAcpSessionQuotaExceeded Code = "acp.session_quota_exceeded"
|
||||
CodeAcpSessionNotOwned Code = "acp.session_not_owned"
|
||||
CodeAcpSessionTerminated Code = "acp.session_terminated"
|
||||
CodeAcpSessionBranchConflict Code = "acp.session_branch_conflict"
|
||||
CodeAcpSpawnFailed Code = "acp.spawn_failed"
|
||||
CodeAcpHandshakeTimeout Code = "acp.handshake_timeout"
|
||||
CodeAcpFsPathOutsideWorktree Code = "acp.fs_path_outside_worktree"
|
||||
CodeAcpPermissionNotFound Code = "acp.permission_not_found"
|
||||
CodeAcpPermissionAlreadyDecided Code = "acp.permission_already_decided"
|
||||
CodeAcpPermissionInvalidOption Code = "acp.permission_invalid_option"
|
||||
|
||||
// MCP 域
|
||||
CodeMcpTokenInvalid Code = "mcp.token_invalid"
|
||||
CodeMcpTokenRevoked Code = "mcp.token_revoked"
|
||||
CodeMcpTokenExpired Code = "mcp.token_expired"
|
||||
CodeMcpTokenNotFound Code = "mcp.token_not_found"
|
||||
CodeMcpTokenScopeViolation Code = "mcp.token_scope_violation"
|
||||
CodeMcpTokenNameRequired Code = "mcp.token_name_required"
|
||||
CodeMcpTokenExpiresRequired Code = "mcp.token_expires_required"
|
||||
CodeMcpRateLimited Code = "mcp.rate_limited"
|
||||
CodeMcpTokenInvalid Code = "mcp.token_invalid"
|
||||
CodeMcpTokenRevoked Code = "mcp.token_revoked"
|
||||
CodeMcpTokenExpired Code = "mcp.token_expired"
|
||||
CodeMcpTokenNotFound Code = "mcp.token_not_found"
|
||||
CodeMcpTokenScopeViolation Code = "mcp.token_scope_violation"
|
||||
CodeMcpTokenNameRequired Code = "mcp.token_name_required"
|
||||
CodeMcpTokenExpiresRequired Code = "mcp.token_expires_required"
|
||||
CodeMcpRateLimited Code = "mcp.rate_limited"
|
||||
)
|
||||
|
||||
// AppError is the application's structured error type. It carries a stable
|
||||
@@ -218,6 +221,12 @@ func HTTPStatus(code Code) int {
|
||||
return http.StatusBadGateway
|
||||
case CodeAcpFsPathOutsideWorktree:
|
||||
return http.StatusInternalServerError
|
||||
case CodeAcpPermissionNotFound:
|
||||
return http.StatusNotFound
|
||||
case CodeAcpPermissionAlreadyDecided:
|
||||
return http.StatusConflict
|
||||
case CodeAcpPermissionInvalidOption:
|
||||
return http.StatusBadRequest
|
||||
case CodeWebhookNotConfigured:
|
||||
return http.StatusServiceUnavailable
|
||||
case CodeMcpTokenInvalid, CodeMcpTokenRevoked, CodeMcpTokenExpired:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -33,6 +33,25 @@ func (s *fakeUserSvc) Bootstrap(_ context.Context, _, _ string) (*user.User, err
|
||||
}
|
||||
func (s *fakeUserSvc) ListAdmins(_ context.Context) ([]*user.User, error) { return nil, nil }
|
||||
|
||||
func (s *fakeUserSvc) ListUsers(_ context.Context) ([]*user.User, error) { return nil, nil }
|
||||
func (s *fakeUserSvc) CreateUser(_ context.Context, _ user.CreateUserInput) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *fakeUserSvc) UpdateProfile(_ context.Context, _ uuid.UUID, _ string) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *fakeUserSvc) SetAdmin(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *fakeUserSvc) SetEnabled(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *fakeUserSvc) AdminResetPassword(_ context.Context, _ uuid.UUID) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (s *fakeUserSvc) ChangePassword(_ context.Context, _ uuid.UUID, _, _ string) error { return nil }
|
||||
func (s *fakeUserSvc) DeleteUser(_ context.Context, _, _ uuid.UUID) error { return nil }
|
||||
|
||||
func ctxWithSession(s *mcp.AuthSession) context.Context {
|
||||
return context.WithValue(context.Background(), mcp.AuthContextKey, s)
|
||||
}
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -26,7 +26,7 @@ type fakeUserSvc struct{ users map[uuid.UUID]*user.User }
|
||||
func (s *fakeUserSvc) Login(_ context.Context, _, _, _ string) (string, *user.User, error) {
|
||||
return "", nil, nil
|
||||
}
|
||||
func (s *fakeUserSvc) Logout(_ context.Context, _ string) error { return nil }
|
||||
func (s *fakeUserSvc) Logout(_ context.Context, _ string) error { return nil }
|
||||
func (s *fakeUserSvc) ResolveSession(_ context.Context, _ string) (uuid.UUID, bool, error) {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
@@ -41,6 +41,25 @@ func (s *fakeUserSvc) Bootstrap(_ context.Context, _, _ string) (*user.User, err
|
||||
}
|
||||
func (s *fakeUserSvc) ListAdmins(_ context.Context) ([]*user.User, error) { return nil, nil }
|
||||
|
||||
func (s *fakeUserSvc) ListUsers(_ context.Context) ([]*user.User, error) { return nil, nil }
|
||||
func (s *fakeUserSvc) CreateUser(_ context.Context, _ user.CreateUserInput) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *fakeUserSvc) UpdateProfile(_ context.Context, _ uuid.UUID, _ string) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *fakeUserSvc) SetAdmin(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *fakeUserSvc) SetEnabled(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *fakeUserSvc) AdminResetPassword(_ context.Context, _ uuid.UUID) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (s *fakeUserSvc) ChangePassword(_ context.Context, _ uuid.UUID, _, _ string) error { return nil }
|
||||
func (s *fakeUserSvc) DeleteUser(_ context.Context, _, _ uuid.UUID) error { return nil }
|
||||
|
||||
// fakeProjectSvc implements project.ProjectService.
|
||||
type fakeProjectSvc struct {
|
||||
projects []*project.Project
|
||||
@@ -78,8 +97,12 @@ func (f *fakeProjectSvc) List(_ context.Context, _ project.Caller, _ bool) ([]*p
|
||||
func (f *fakeProjectSvc) Update(_ context.Context, _ project.Caller, _ string, _ project.UpdateProjectInput) (*project.Project, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (f *fakeProjectSvc) Archive(_ context.Context, _ project.Caller, _ string) error { panic("not implemented") }
|
||||
func (f *fakeProjectSvc) Unarchive(_ context.Context, _ project.Caller, _ string) error { panic("not implemented") }
|
||||
func (f *fakeProjectSvc) Archive(_ context.Context, _ project.Caller, _ string) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (f *fakeProjectSvc) Unarchive(_ context.Context, _ project.Caller, _ string) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
// fakeRequirementSvc implements project.RequirementService.
|
||||
type fakeRequirementSvc struct {
|
||||
@@ -231,7 +254,9 @@ func (f *fakeWorkspaceSvc) List(_ context.Context, _ workspace.Caller, _ string)
|
||||
func (f *fakeWorkspaceSvc) Update(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (f *fakeWorkspaceSvc) Delete(_ context.Context, _ workspace.Caller, _ uuid.UUID) error { panic("not implemented") }
|
||||
func (f *fakeWorkspaceSvc) Delete(_ context.Context, _ workspace.Caller, _ uuid.UUID) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (f *fakeWorkspaceSvc) Sync(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Workspace, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,6 +22,7 @@ type User struct {
|
||||
Email string
|
||||
DisplayName string
|
||||
IsAdmin bool
|
||||
Enabled bool
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
@@ -58,4 +59,33 @@ type Service interface {
|
||||
Bootstrap(ctx context.Context, email, password string) (*User, error)
|
||||
// ListAdmins 返回所有管理员用户,供内部模块(如 jobs runner)使用。
|
||||
ListAdmins(ctx context.Context) ([]*User, error)
|
||||
|
||||
// ===== 管理员用户管理(需调用方自行做 admin 鉴权)=====
|
||||
|
||||
// ListUsers 返回全部用户,按创建时间升序,供管理后台展示。
|
||||
ListUsers(ctx context.Context) ([]*User, error)
|
||||
// CreateUser 由管理员创建一个新账号。email 唯一冲突时返回 CodeConflict。
|
||||
CreateUser(ctx context.Context, in CreateUserInput) (*User, error)
|
||||
// UpdateProfile 修改用户的 display_name。
|
||||
UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error)
|
||||
// SetAdmin 设置用户的管理员标志。降级最后一个启用管理员时返回 CodeConflict;
|
||||
// actor==target 且降级自身时返回 CodeConflict(防自锁)。
|
||||
SetAdmin(ctx context.Context, actor, target uuid.UUID, isAdmin bool) (*User, error)
|
||||
// SetEnabled 启用/禁用用户。禁用会撤销其全部会话。禁用最后一个启用管理员或
|
||||
// 禁用自身时返回 CodeConflict。
|
||||
SetEnabled(ctx context.Context, actor, target uuid.UUID, enabled bool) (*User, error)
|
||||
// AdminResetPassword 由管理员重置目标用户密码,返回一次性明文临时密码并撤销其会话。
|
||||
AdminResetPassword(ctx context.Context, id uuid.UUID) (tempPassword string, err error)
|
||||
// ChangePassword 用户自助改密:校验旧密码后更新,并撤销其它会话。
|
||||
ChangePassword(ctx context.Context, id uuid.UUID, oldPassword, newPassword string) error
|
||||
// DeleteUser 删除用户。删除最后一个启用管理员或删除自身时返回 CodeConflict。
|
||||
DeleteUser(ctx context.Context, actor, target uuid.UUID) error
|
||||
}
|
||||
|
||||
// CreateUserInput 是管理员创建用户的入参。
|
||||
type CreateUserInput struct {
|
||||
Email string
|
||||
DisplayName string
|
||||
Password string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
+303
-1
@@ -18,8 +18,10 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||
@@ -42,15 +44,47 @@ func NewHandler(svc Service) *Handler { return &Handler{svc: svc} }
|
||||
// Mount 把 user 模块的所有 HTTP 端点挂到给定 router 的 /api/v1/auth 子树。
|
||||
// 调用方负责在挂载之前装好 RequestID/Recover/Logger 等通用中间件。
|
||||
func (h *Handler) Mount(r chi.Router) {
|
||||
auth := middleware.Auth(h.svc, middleware.AuthOptions{})
|
||||
|
||||
r.Route("/api/v1/auth", func(r chi.Router) {
|
||||
r.Post("/login", h.login)
|
||||
|
||||
// /me 与 /logout 必须经过 Auth 中间件:未带 token 直接 401,由中间件
|
||||
// 落地响应;带了 token 才会走到下面的处理函数。
|
||||
auth := middleware.Auth(h.svc, middleware.AuthOptions{})
|
||||
r.With(auth).Get("/me", h.me)
|
||||
r.With(auth).Post("/logout", h.logout)
|
||||
// 自助改密:需认证,校验旧密码后撤销全部会话。
|
||||
r.With(auth).Post("/change-password", h.changePassword)
|
||||
})
|
||||
|
||||
// 管理员用户管理子树:全部经 Auth,handler 内部再用 requireAdmin 做 admin 鉴权。
|
||||
r.Route("/api/v1/admin/users", func(r chi.Router) {
|
||||
r.Use(auth)
|
||||
r.Get("/", h.listUsers)
|
||||
r.Post("/", h.createUser)
|
||||
r.Get("/{id}", h.getUser)
|
||||
r.Patch("/{id}", h.updateUser)
|
||||
r.Delete("/{id}", h.deleteUser)
|
||||
r.Patch("/{id}/role", h.setRole)
|
||||
r.Patch("/{id}/enabled", h.setEnabled)
|
||||
r.Post("/{id}/reset-password", h.resetPassword)
|
||||
})
|
||||
}
|
||||
|
||||
// requireAdmin 解析当前认证用户并校验其为管理员。返回 actor 的 UserID。
|
||||
func (h *Handler) requireAdmin(r *http.Request) (uuid.UUID, error) {
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
return uuid.Nil, errs.New(errs.CodeUnauthorized, "not authenticated")
|
||||
}
|
||||
u, err := h.svc.Get(r.Context(), uid)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if !u.IsAdmin {
|
||||
return uuid.Nil, errs.New(errs.CodeForbidden, "需要管理员权限")
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
// loginReq 是 POST /api/v1/auth/login 的请求体形态。
|
||||
@@ -161,3 +195,271 @@ func clientIP(r *http.Request) string {
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// ===== 管理员用户管理 =====
|
||||
|
||||
// adminUserDTO 是管理后台的用户视图,比 userDTO 多 enabled 与时间戳,但同样
|
||||
// 不暴露 password_hash。
|
||||
type adminUserDTO struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toAdminUserDTO(u *User) adminUserDTO {
|
||||
return adminUserDTO{
|
||||
ID: u.ID.String(),
|
||||
Email: u.Email,
|
||||
DisplayName: u.DisplayName,
|
||||
IsAdmin: u.IsAdmin,
|
||||
Enabled: u.Enabled,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// userIDParam 解析路由中的 {id} 为 uuid。
|
||||
func userIDParam(r *http.Request) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
return uuid.Nil, errs.New(errs.CodeInvalidInput, "无效的用户 id")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
rid := middleware.RequestIDFromContext(r.Context())
|
||||
if _, err := h.requireAdmin(r); err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
users, err := h.svc.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
out := make([]adminUserDTO, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, toAdminUserDTO(u))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type createUserReq struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
rid := middleware.RequestIDFromContext(r.Context())
|
||||
if _, err := h.requireAdmin(r); err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
var req createUserReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效"))
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
display := strings.TrimSpace(req.DisplayName)
|
||||
if email == "" || display == "" {
|
||||
httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "缺少邮箱或显示名"))
|
||||
return
|
||||
}
|
||||
u, err := h.svc.CreateUser(r.Context(), CreateUserInput{
|
||||
Email: email,
|
||||
DisplayName: display,
|
||||
Password: req.Password,
|
||||
IsAdmin: req.IsAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, toAdminUserDTO(u))
|
||||
}
|
||||
|
||||
func (h *Handler) getUser(w http.ResponseWriter, r *http.Request) {
|
||||
rid := middleware.RequestIDFromContext(r.Context())
|
||||
if _, err := h.requireAdmin(r); err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
id, err := userIDParam(r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
u, err := h.svc.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u))
|
||||
}
|
||||
|
||||
type updateUserReq struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
rid := middleware.RequestIDFromContext(r.Context())
|
||||
if _, err := h.requireAdmin(r); err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
id, err := userIDParam(r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
var req updateUserReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效"))
|
||||
return
|
||||
}
|
||||
display := strings.TrimSpace(req.DisplayName)
|
||||
if display == "" {
|
||||
httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "显示名不能为空"))
|
||||
return
|
||||
}
|
||||
u, err := h.svc.UpdateProfile(r.Context(), id, display)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u))
|
||||
}
|
||||
|
||||
func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
rid := middleware.RequestIDFromContext(r.Context())
|
||||
actor, err := h.requireAdmin(r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
id, err := userIDParam(r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteUser(r.Context(), actor, id); err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type setRoleReq struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
func (h *Handler) setRole(w http.ResponseWriter, r *http.Request) {
|
||||
rid := middleware.RequestIDFromContext(r.Context())
|
||||
actor, err := h.requireAdmin(r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
id, err := userIDParam(r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
var req setRoleReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效"))
|
||||
return
|
||||
}
|
||||
u, err := h.svc.SetAdmin(r.Context(), actor, id, req.IsAdmin)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u))
|
||||
}
|
||||
|
||||
type setEnabledReq struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handler) setEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
rid := middleware.RequestIDFromContext(r.Context())
|
||||
actor, err := h.requireAdmin(r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
id, err := userIDParam(r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
var req setEnabledReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效"))
|
||||
return
|
||||
}
|
||||
u, err := h.svc.SetEnabled(r.Context(), actor, id, req.Enabled)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u))
|
||||
}
|
||||
|
||||
type resetPasswordResp struct {
|
||||
TempPassword string `json:"temp_password"`
|
||||
}
|
||||
|
||||
func (h *Handler) resetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
rid := middleware.RequestIDFromContext(r.Context())
|
||||
if _, err := h.requireAdmin(r); err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
id, err := userIDParam(r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
temp, err := h.svc.AdminResetPassword(r.Context(), id)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, resetPasswordResp{TempPassword: temp})
|
||||
}
|
||||
|
||||
// ===== 自助改密 =====
|
||||
|
||||
type changePasswordReq struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
func (h *Handler) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
rid := middleware.RequestIDFromContext(r.Context())
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
httpx.WriteError(w, rid, errs.New(errs.CodeInternal, "missing auth context"))
|
||||
return
|
||||
}
|
||||
var req changePasswordReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效"))
|
||||
return
|
||||
}
|
||||
if err := h.svc.ChangePassword(r.Context(), uid, req.OldPassword, req.NewPassword); err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,66 @@
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (id, email, password_hash, display_name, is_admin)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at;
|
||||
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
FROM users
|
||||
WHERE email = $1;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
FROM users
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ListUsers :many
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
FROM users
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: CountUsers :one
|
||||
SELECT COUNT(*)::int FROM users;
|
||||
|
||||
-- name: CountAdmins :one
|
||||
SELECT COUNT(*)::int FROM users WHERE is_admin = true AND enabled = true;
|
||||
|
||||
-- name: UpdateUserProfile :one
|
||||
UPDATE users
|
||||
SET display_name = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled;
|
||||
|
||||
-- name: SetUserAdmin :one
|
||||
UPDATE users
|
||||
SET is_admin = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled;
|
||||
|
||||
-- name: SetUserEnabled :one
|
||||
UPDATE users
|
||||
SET enabled = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled;
|
||||
|
||||
-- name: UpdateUserPassword :exec
|
||||
UPDATE users
|
||||
SET password_hash = $2, updated_at = now()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: DeleteUser :exec
|
||||
DELETE FROM users WHERE id = $1;
|
||||
|
||||
-- name: CreateSession :exec
|
||||
INSERT INTO user_sessions (id, user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4);
|
||||
|
||||
-- name: GetSessionByTokenHash :one
|
||||
SELECT id, user_id, token_hash, expires_at, last_seen_at, created_at
|
||||
FROM user_sessions
|
||||
WHERE token_hash = $1
|
||||
AND expires_at > now();
|
||||
SELECT s.id, s.user_id, s.token_hash, s.expires_at, s.last_seen_at, s.created_at
|
||||
FROM user_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1
|
||||
AND s.expires_at > now()
|
||||
AND u.enabled = true;
|
||||
|
||||
-- name: TouchSession :exec
|
||||
UPDATE user_sessions
|
||||
@@ -34,11 +70,14 @@ WHERE token_hash = $1;
|
||||
-- name: DeleteSessionByTokenHash :exec
|
||||
DELETE FROM user_sessions WHERE token_hash = $1;
|
||||
|
||||
-- name: DeleteSessionsByUserID :exec
|
||||
DELETE FROM user_sessions WHERE user_id = $1;
|
||||
|
||||
-- name: DeleteExpiredSessions :exec
|
||||
DELETE FROM user_sessions WHERE expires_at <= now();
|
||||
|
||||
-- name: ListAdmins :many
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
FROM users
|
||||
WHERE is_admin = true
|
||||
ORDER BY id;
|
||||
|
||||
@@ -20,7 +20,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
@@ -35,12 +37,20 @@ type Repository interface {
|
||||
GetUserByEmail(ctx context.Context, email string) (*User, error)
|
||||
GetUserByID(ctx context.Context, id uuid.UUID) (*User, error)
|
||||
CountUsers(ctx context.Context) (int, error)
|
||||
CountAdmins(ctx context.Context) (int, error)
|
||||
ListUsers(ctx context.Context) ([]*User, error)
|
||||
ListAdmins(ctx context.Context) ([]*User, error)
|
||||
UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error)
|
||||
SetAdmin(ctx context.Context, id uuid.UUID, isAdmin bool) (*User, error)
|
||||
SetEnabled(ctx context.Context, id uuid.UUID, enabled bool) (*User, error)
|
||||
UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error
|
||||
DeleteUser(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
CreateSession(ctx context.Context, s *Session) error
|
||||
GetSessionByTokenHash(ctx context.Context, hash []byte) (*Session, error)
|
||||
TouchSession(ctx context.Context, hash []byte) error
|
||||
DeleteSessionByTokenHash(ctx context.Context, hash []byte) error
|
||||
DeleteSessionsByUserID(ctx context.Context, userID uuid.UUID) error
|
||||
}
|
||||
|
||||
// pgRepo 是 Repository 的 Postgres 实现,内部持有一个 sqlc 生成的 Queries。
|
||||
@@ -80,6 +90,7 @@ func rowToUser(row usersqlc.User) *User {
|
||||
PasswordHash: row.PasswordHash,
|
||||
DisplayName: row.DisplayName,
|
||||
IsAdmin: row.IsAdmin,
|
||||
Enabled: row.Enabled,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
@@ -114,11 +125,20 @@ func (r *pgRepo) CreateUser(ctx context.Context, u *User) (*User, error) {
|
||||
IsAdmin: u.IsAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, errs.New(errs.CodeConflict, "邮箱已被占用")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "create user")
|
||||
}
|
||||
return rowToUser(row), nil
|
||||
}
|
||||
|
||||
// isUniqueViolation 判断底层错误是否为 Postgres 唯一约束冲突(如 email 重复)。
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
|
||||
}
|
||||
|
||||
// GetUserByEmail 按邮箱精确查询用户。pgx.ErrNoRows 翻译为 NotFound,方便
|
||||
// 上层(例如登录流程)分类响应;其它错误一律 Internal。
|
||||
func (r *pgRepo) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
@@ -212,3 +232,97 @@ func (r *pgRepo) ListAdmins(ctx context.Context) ([]*User, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CountAdmins 返回启用状态的管理员数量,用于 service 层的“最后一个管理员”保护。
|
||||
func (r *pgRepo) CountAdmins(ctx context.Context) (int, error) {
|
||||
n, err := r.q.CountAdmins(ctx)
|
||||
if err != nil {
|
||||
return 0, errs.Wrap(err, errs.CodeInternal, "count admins")
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// ListUsers 返回全部用户,按创建时间升序。
|
||||
func (r *pgRepo) ListUsers(ctx context.Context) ([]*User, error) {
|
||||
rows, err := r.q.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list users")
|
||||
}
|
||||
out := make([]*User, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToUser(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateProfile 更新 display_name 并返回更新后的行。目标不存在时返回 NotFound。
|
||||
func (r *pgRepo) UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error) {
|
||||
row, err := r.q.UpdateUserProfile(ctx, usersqlc.UpdateUserProfileParams{
|
||||
ID: toPgUUID(id),
|
||||
DisplayName: displayName,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errs.New(errs.CodeNotFound, "用户不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "update user profile")
|
||||
}
|
||||
return rowToUser(row), nil
|
||||
}
|
||||
|
||||
// SetAdmin 设置 is_admin 并返回更新后的行。目标不存在时返回 NotFound。
|
||||
func (r *pgRepo) SetAdmin(ctx context.Context, id uuid.UUID, isAdmin bool) (*User, error) {
|
||||
row, err := r.q.SetUserAdmin(ctx, usersqlc.SetUserAdminParams{
|
||||
ID: toPgUUID(id),
|
||||
IsAdmin: isAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errs.New(errs.CodeNotFound, "用户不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "set user admin")
|
||||
}
|
||||
return rowToUser(row), nil
|
||||
}
|
||||
|
||||
// SetEnabled 设置 enabled 并返回更新后的行。目标不存在时返回 NotFound。
|
||||
func (r *pgRepo) SetEnabled(ctx context.Context, id uuid.UUID, enabled bool) (*User, error) {
|
||||
row, err := r.q.SetUserEnabled(ctx, usersqlc.SetUserEnabledParams{
|
||||
ID: toPgUUID(id),
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errs.New(errs.CodeNotFound, "用户不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "set user enabled")
|
||||
}
|
||||
return rowToUser(row), nil
|
||||
}
|
||||
|
||||
// UpdatePassword 覆写密码哈希。:exec 不读结果集,目标不存在不报错(幂等)。
|
||||
func (r *pgRepo) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error {
|
||||
if err := r.q.UpdateUserPassword(ctx, usersqlc.UpdateUserPasswordParams{
|
||||
ID: toPgUUID(id),
|
||||
PasswordHash: passwordHash,
|
||||
}); err != nil {
|
||||
return errs.Wrap(err, errs.CodeInternal, "update user password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser 删除用户。其会话经 ON DELETE CASCADE 一并清除。
|
||||
func (r *pgRepo) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||
if err := r.q.DeleteUser(ctx, toPgUUID(id)); err != nil {
|
||||
return errs.Wrap(err, errs.CodeInternal, "delete user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSessionsByUserID 撤销某用户的全部会话(改密/禁用时使用)。
|
||||
func (r *pgRepo) DeleteSessionsByUserID(ctx context.Context, userID uuid.UUID) error {
|
||||
if err := r.q.DeleteSessionsByUserID(ctx, toPgUUID(userID)); err != nil {
|
||||
return errs.Wrap(err, errs.CodeInternal, "delete sessions by user id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -177,3 +177,202 @@ func (s *service) Bootstrap(ctx context.Context, email, password string) (*User,
|
||||
func (s *service) ListAdmins(ctx context.Context) ([]*User, error) {
|
||||
return s.repo.ListAdmins(ctx)
|
||||
}
|
||||
|
||||
// tempPasswordLen 是管理员重置密码时生成的临时明文长度(base64 字符数)。
|
||||
// 16 字符远超 minPasswordLen,且使用与会话 token 相同的强随机源。
|
||||
const tempPasswordLen = 16
|
||||
|
||||
// ListUsers 返回全部用户,供管理后台展示。调用方负责 admin 鉴权。
|
||||
func (s *service) ListUsers(ctx context.Context) ([]*User, error) {
|
||||
return s.repo.ListUsers(ctx)
|
||||
}
|
||||
|
||||
// CreateUser 由管理员创建新账号。密码长度由 HashPassword 校验;email 冲突时
|
||||
// Repository 返回 CodeConflict。
|
||||
func (s *service) CreateUser(ctx context.Context, in CreateUserInput) (*User, error) {
|
||||
hash, err := HashPassword(in.Password)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInvalidInput, "password")
|
||||
}
|
||||
u := &User{
|
||||
ID: uuid.New(),
|
||||
Email: in.Email,
|
||||
PasswordHash: hash,
|
||||
DisplayName: in.DisplayName,
|
||||
IsAdmin: in.IsAdmin,
|
||||
}
|
||||
out, err := s.repo.CreateUser(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.record(ctx, &out.ID, "user.create", out.ID.String())
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateProfile 修改用户 display_name。
|
||||
func (s *service) UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error) {
|
||||
out, err := s.repo.UpdateProfile(ctx, id, displayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.record(ctx, &id, "user.update_profile", id.String())
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetAdmin 调整用户管理员标志。两条护栏:actor 不能降级自己(防误操作把自己
|
||||
// 锁在门外);不能降级最后一个启用管理员(防系统失去管理员)。
|
||||
func (s *service) SetAdmin(ctx context.Context, actor, target uuid.UUID, isAdmin bool) (*User, error) {
|
||||
if !isAdmin {
|
||||
if actor == target {
|
||||
return nil, errs.New(errs.CodeConflict, "不能取消自己的管理员权限")
|
||||
}
|
||||
if err := s.guardLastAdmin(ctx, target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out, err := s.repo.SetAdmin(ctx, target, isAdmin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.record(ctx, &actor, "user.set_admin", target.String())
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetEnabled 启用/禁用用户。禁用时撤销其全部会话使其立即下线,并施加与
|
||||
// SetAdmin 同样的自锁/最后管理员护栏。
|
||||
func (s *service) SetEnabled(ctx context.Context, actor, target uuid.UUID, enabled bool) (*User, error) {
|
||||
if !enabled {
|
||||
if actor == target {
|
||||
return nil, errs.New(errs.CodeConflict, "不能禁用自己")
|
||||
}
|
||||
if err := s.guardLastAdmin(ctx, target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out, err := s.repo.SetEnabled(ctx, target, enabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enabled {
|
||||
// best-effort:撤销会话失败不应让禁用操作回滚(enabled=false 已生效,
|
||||
// ResolveSession 会拒绝该用户)。
|
||||
_ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), target)
|
||||
}
|
||||
s.record(ctx, &actor, "user.set_enabled", target.String())
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminResetPassword 由管理员重置目标密码,生成强随机临时密码、撤销其全部
|
||||
// 会话,并返回临时明文(仅此一次)。
|
||||
func (s *service) AdminResetPassword(ctx context.Context, id uuid.UUID) (string, error) {
|
||||
if _, err := s.repo.GetUserByID(ctx, id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
temp, err := randomPassword()
|
||||
if err != nil {
|
||||
return "", errs.Wrap(err, errs.CodeInternal, "generate temp password")
|
||||
}
|
||||
hash, err := HashPassword(temp)
|
||||
if err != nil {
|
||||
return "", errs.Wrap(err, errs.CodeInternal, "hash temp password")
|
||||
}
|
||||
if err := s.repo.UpdatePassword(ctx, id, hash); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), id)
|
||||
s.record(ctx, &id, "user.reset_password", id.String())
|
||||
return temp, nil
|
||||
}
|
||||
|
||||
// ChangePassword 用户自助改密:校验旧密码后更新,并撤销其全部会话(含当前),
|
||||
// 强制用新密码重新登录。
|
||||
func (s *service) ChangePassword(ctx context.Context, id uuid.UUID, oldPassword, newPassword string) error {
|
||||
u, err := s.repo.GetUserByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok, err := VerifyPassword(oldPassword, u.PasswordHash)
|
||||
if err != nil {
|
||||
return errs.Wrap(err, errs.CodeInternal, "verify password")
|
||||
}
|
||||
if !ok {
|
||||
return errs.New(errs.CodeUnauthorized, "旧密码错误")
|
||||
}
|
||||
hash, err := HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return errs.Wrap(err, errs.CodeInvalidInput, "password")
|
||||
}
|
||||
if err := s.repo.UpdatePassword(ctx, id, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), id)
|
||||
s.record(ctx, &id, "user.change_password", id.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser 删除用户。同样施加自锁与最后管理员护栏。其会话经 DB 外键级联清除。
|
||||
func (s *service) DeleteUser(ctx context.Context, actor, target uuid.UUID) error {
|
||||
if actor == target {
|
||||
return errs.New(errs.CodeConflict, "不能删除自己")
|
||||
}
|
||||
t, err := s.repo.GetUserByID(ctx, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.IsAdmin && t.Enabled {
|
||||
if err := s.guardLastAdmin(ctx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.repo.DeleteUser(ctx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
s.record(ctx, &actor, "user.delete", target.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// guardLastAdmin 在降级/禁用/删除目标会导致“无启用管理员”时返回 CodeConflict。
|
||||
// 仅当目标本身是启用管理员且系统启用管理员数 <= 1 时触发。
|
||||
func (s *service) guardLastAdmin(ctx context.Context, target uuid.UUID) error {
|
||||
t, err := s.repo.GetUserByID(ctx, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !t.IsAdmin || !t.Enabled {
|
||||
return nil
|
||||
}
|
||||
n, err := s.repo.CountAdmins(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return errs.New(errs.CodeConflict, "必须保留至少一个启用的管理员")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// record 是审计的薄封装:recorder 为 nil 时跳过;用 WithoutCancel 派生 ctx 保证
|
||||
// 响应返回后审计仍能落库。
|
||||
func (s *service) record(ctx context.Context, actor *uuid.UUID, action, targetID string) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
}
|
||||
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
|
||||
UserID: actor,
|
||||
Action: action,
|
||||
TargetType: "user",
|
||||
TargetID: targetID,
|
||||
})
|
||||
}
|
||||
|
||||
// randomPassword 生成一段强随机 base64 临时密码,用于管理员重置场景。
|
||||
func randomPassword() (string, error) {
|
||||
tok, _, err := NewSessionToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(tok) > tempPasswordLen {
|
||||
tok = tok[:tempPasswordLen]
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
@@ -48,7 +48,97 @@ func (r *fakeRepo) GetUserByID(_ context.Context, id uuid.UUID) (*User, error) {
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CountUsers(_ context.Context) (int, error) { return len(r.users), nil }
|
||||
func (r *fakeRepo) ListAdmins(_ context.Context) ([]*User, error) { return nil, nil }
|
||||
func (r *fakeRepo) ListAdmins(_ context.Context) ([]*User, error) {
|
||||
out := make([]*User, 0)
|
||||
for _, u := range r.users {
|
||||
if u.IsAdmin {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CountAdmins(_ context.Context) (int, error) {
|
||||
n := 0
|
||||
for _, u := range r.users {
|
||||
if u.IsAdmin && u.Enabled {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListUsers(_ context.Context) ([]*User, error) {
|
||||
out := make([]*User, 0, len(r.users))
|
||||
for _, u := range r.users {
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) findByID(id uuid.UUID) (*User, bool) {
|
||||
for _, u := range r.users {
|
||||
if u.ID == id {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpdateProfile(_ context.Context, id uuid.UUID, displayName string) (*User, error) {
|
||||
u, ok := r.findByID(id)
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "no user")
|
||||
}
|
||||
u.DisplayName = displayName
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) SetAdmin(_ context.Context, id uuid.UUID, isAdmin bool) (*User, error) {
|
||||
u, ok := r.findByID(id)
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "no user")
|
||||
}
|
||||
u.IsAdmin = isAdmin
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) SetEnabled(_ context.Context, id uuid.UUID, enabled bool) (*User, error) {
|
||||
u, ok := r.findByID(id)
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "no user")
|
||||
}
|
||||
u.Enabled = enabled
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpdatePassword(_ context.Context, id uuid.UUID, passwordHash string) error {
|
||||
u, ok := r.findByID(id)
|
||||
if !ok {
|
||||
return errs.New(errs.CodeNotFound, "no user")
|
||||
}
|
||||
u.PasswordHash = passwordHash
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) DeleteUser(_ context.Context, id uuid.UUID) error {
|
||||
for email, u := range r.users {
|
||||
if u.ID == id {
|
||||
delete(r.users, email)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) DeleteSessionsByUserID(_ context.Context, userID uuid.UUID) error {
|
||||
for k, s := range r.sessions {
|
||||
if s.UserID == userID {
|
||||
delete(r.sessions, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CreateSession(_ context.Context, s *Session) error {
|
||||
r.sessions[string(s.TokenHash)] = s
|
||||
@@ -168,3 +258,124 @@ func TestLogout_RecordsAudit(t *testing.T) {
|
||||
require.Len(t, spy.entries, 1)
|
||||
require.Equal(t, "user.logout", spy.entries[0].Action)
|
||||
}
|
||||
|
||||
// ===== 管理员用户管理测试 =====
|
||||
|
||||
// newAdminRepo 构造一个含一个启用管理员的 fakeRepo + service。
|
||||
func newAdminRepo(t *testing.T) (*service, *fakeRepo, *User) {
|
||||
t.Helper()
|
||||
repo := newFakeRepo()
|
||||
hash, err := HashPassword("password123")
|
||||
require.NoError(t, err)
|
||||
admin := &User{ID: uuid.New(), Email: "admin@b.c", DisplayName: "Admin", PasswordHash: hash, IsAdmin: true, Enabled: true}
|
||||
repo.users[admin.Email] = admin
|
||||
svc := NewService(repo, nil).(*service)
|
||||
return svc, repo, admin
|
||||
}
|
||||
|
||||
func TestCreateUser_AndListUsers(t *testing.T) {
|
||||
svc, _, _ := newAdminRepo(t)
|
||||
u, err := svc.CreateUser(context.Background(), CreateUserInput{
|
||||
Email: "new@b.c", DisplayName: "New", Password: "password123", IsAdmin: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "new@b.c", u.Email)
|
||||
users, err := svc.ListUsers(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 2)
|
||||
}
|
||||
|
||||
func TestCreateUser_ShortPassword(t *testing.T) {
|
||||
svc, _, _ := newAdminRepo(t)
|
||||
_, err := svc.CreateUser(context.Background(), CreateUserInput{
|
||||
Email: "new@b.c", DisplayName: "New", Password: "short",
|
||||
})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
func TestSetAdmin_CannotDemoteSelf(t *testing.T) {
|
||||
svc, _, admin := newAdminRepo(t)
|
||||
_, err := svc.SetAdmin(context.Background(), admin.ID, admin.ID, false)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeConflict, ae.Code)
|
||||
}
|
||||
|
||||
func TestSetAdmin_CannotDemoteLastAdmin(t *testing.T) {
|
||||
svc, repo, admin := newAdminRepo(t)
|
||||
// 加一个普通用户作 actor,避免触发自锁分支
|
||||
actor := &User{ID: uuid.New(), Email: "actor@b.c", DisplayName: "A", IsAdmin: true, Enabled: true}
|
||||
repo.users[actor.Email] = actor
|
||||
// 现在有 2 个 admin;降级其中一个应成功
|
||||
_, err := svc.SetAdmin(context.Background(), actor.ID, admin.ID, false)
|
||||
require.NoError(t, err)
|
||||
// 只剩 actor 一个 admin;再降级 actor(由自己以外触发不可能,构造另一普通用户)
|
||||
plain := &User{ID: uuid.New(), Email: "p@b.c", DisplayName: "P", IsAdmin: false, Enabled: true}
|
||||
repo.users[plain.Email] = plain
|
||||
_, err = svc.SetAdmin(context.Background(), plain.ID, actor.ID, false)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeConflict, ae.Code)
|
||||
}
|
||||
|
||||
func TestSetEnabled_DisableRevokesSessions(t *testing.T) {
|
||||
svc, repo, _ := newAdminRepo(t)
|
||||
target := &User{ID: uuid.New(), Email: "t@b.c", DisplayName: "T", IsAdmin: false, Enabled: true}
|
||||
repo.users[target.Email] = target
|
||||
repo.sessions["s1"] = &Session{UserID: target.ID, TokenHash: []byte("s1")}
|
||||
actor := &User{ID: uuid.New(), Email: "act@b.c", IsAdmin: true, Enabled: true}
|
||||
repo.users[actor.Email] = actor
|
||||
_, err := svc.SetEnabled(context.Background(), actor.ID, target.ID, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, repo.users["t@b.c"].Enabled)
|
||||
require.Empty(t, repo.sessions)
|
||||
}
|
||||
|
||||
func TestChangePassword_WrongOld(t *testing.T) {
|
||||
svc, _, admin := newAdminRepo(t)
|
||||
err := svc.ChangePassword(context.Background(), admin.ID, "wrong-old", "newpassword123")
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeUnauthorized, ae.Code)
|
||||
}
|
||||
|
||||
func TestChangePassword_Success(t *testing.T) {
|
||||
svc, repo, admin := newAdminRepo(t)
|
||||
repo.sessions["s1"] = &Session{UserID: admin.ID, TokenHash: []byte("s1")}
|
||||
err := svc.ChangePassword(context.Background(), admin.ID, "password123", "newpassword123")
|
||||
require.NoError(t, err)
|
||||
ok, err := VerifyPassword("newpassword123", repo.users["admin@b.c"].PasswordHash)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Empty(t, repo.sessions) // 改密撤销全部会话
|
||||
}
|
||||
|
||||
func TestAdminResetPassword_ReturnsTemp(t *testing.T) {
|
||||
svc, repo, admin := newAdminRepo(t)
|
||||
temp, err := svc.AdminResetPassword(context.Background(), admin.ID)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(temp), minPasswordLen)
|
||||
ok, err := VerifyPassword(temp, repo.users["admin@b.c"].PasswordHash)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
func TestDeleteUser_CannotDeleteSelf(t *testing.T) {
|
||||
svc, _, admin := newAdminRepo(t)
|
||||
err := svc.DeleteUser(context.Background(), admin.ID, admin.ID)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeConflict, ae.Code)
|
||||
}
|
||||
|
||||
func TestDeleteUser_CannotDeleteLastAdmin(t *testing.T) {
|
||||
svc, repo, admin := newAdminRepo(t)
|
||||
actor := &User{ID: uuid.New(), Email: "act@b.c", IsAdmin: false, Enabled: true}
|
||||
repo.users[actor.Email] = actor
|
||||
err := svc.DeleteUser(context.Background(), actor.ID, admin.ID)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeConflict, ae.Code)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,6 +11,17 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countAdmins = `-- name: CountAdmins :one
|
||||
SELECT COUNT(*)::int FROM users WHERE is_admin = true AND enabled = true
|
||||
`
|
||||
|
||||
func (q *Queries) CountAdmins(ctx context.Context) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, countAdmins)
|
||||
var column_1 int32
|
||||
err := row.Scan(&column_1)
|
||||
return column_1, err
|
||||
}
|
||||
|
||||
const countUsers = `-- name: CountUsers :one
|
||||
SELECT COUNT(*)::int FROM users
|
||||
`
|
||||
@@ -47,7 +58,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (id, email, password_hash, display_name, is_admin)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at
|
||||
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
@@ -75,6 +86,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Enabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -97,11 +109,31 @@ func (q *Queries) DeleteSessionByTokenHash(ctx context.Context, tokenHash []byte
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSessionsByUserID = `-- name: DeleteSessionsByUserID :exec
|
||||
DELETE FROM user_sessions WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSessionsByUserID(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteSessionsByUserID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteUser = `-- name: DeleteUser :exec
|
||||
DELETE FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteUser, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
|
||||
SELECT id, user_id, token_hash, expires_at, last_seen_at, created_at
|
||||
FROM user_sessions
|
||||
WHERE token_hash = $1
|
||||
AND expires_at > now()
|
||||
SELECT s.id, s.user_id, s.token_hash, s.expires_at, s.last_seen_at, s.created_at
|
||||
FROM user_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1
|
||||
AND s.expires_at > now()
|
||||
AND u.enabled = true
|
||||
`
|
||||
|
||||
func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (UserSession, error) {
|
||||
@@ -119,7 +151,7 @@ func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (
|
||||
}
|
||||
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
`
|
||||
@@ -135,12 +167,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Enabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -156,12 +189,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Enabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAdmins = `-- name: ListAdmins :many
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
FROM users
|
||||
WHERE is_admin = true
|
||||
ORDER BY id
|
||||
@@ -184,6 +218,7 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) {
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Enabled,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -195,6 +230,97 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listUsers = `-- name: ListUsers :many
|
||||
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
FROM users
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := q.db.Query(ctx, listUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []User
|
||||
for rows.Next() {
|
||||
var i User
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.PasswordHash,
|
||||
&i.DisplayName,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Enabled,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setUserAdmin = `-- name: SetUserAdmin :one
|
||||
UPDATE users
|
||||
SET is_admin = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
`
|
||||
|
||||
type SetUserAdminParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, setUserAdmin, arg.ID, arg.IsAdmin)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.PasswordHash,
|
||||
&i.DisplayName,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Enabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const setUserEnabled = `-- name: SetUserEnabled :one
|
||||
UPDATE users
|
||||
SET enabled = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
`
|
||||
|
||||
type SetUserEnabledParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetUserEnabled(ctx context.Context, arg SetUserEnabledParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, setUserEnabled, arg.ID, arg.Enabled)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.PasswordHash,
|
||||
&i.DisplayName,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Enabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const touchSession = `-- name: TouchSession :exec
|
||||
UPDATE user_sessions
|
||||
SET last_seen_at = now()
|
||||
@@ -205,3 +331,47 @@ func (q *Queries) TouchSession(ctx context.Context, tokenHash []byte) error {
|
||||
_, err := q.db.Exec(ctx, touchSession, tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateUserPassword = `-- name: UpdateUserPassword :exec
|
||||
UPDATE users
|
||||
SET password_hash = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdateUserPasswordParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error {
|
||||
_, err := q.db.Exec(ctx, updateUserPassword, arg.ID, arg.PasswordHash)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateUserProfile = `-- name: UpdateUserProfile :one
|
||||
UPDATE users
|
||||
SET display_name = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
|
||||
`
|
||||
|
||||
type UpdateUserProfileParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, updateUserProfile, arg.ID, arg.DisplayName)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.PasswordHash,
|
||||
&i.DisplayName,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Enabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user