This commit is contained in:
2026-06-09 21:19:30 +08:00
parent 8b41ff9360
commit 0484e79978
66 changed files with 4043 additions and 533 deletions
+10 -1
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"),
}
}
+4 -1
View File
@@ -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,
+1
View File
@@ -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,
+2 -2
View File
@@ -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()}
}
+48 -26
View File
@@ -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 -16
View File
@@ -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))
}
+2 -2
View File
@@ -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
+397
View File
@@ -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
}
+267
View File
@@ -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()))
}
+16 -15
View File
@@ -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;
+44
View File
@@ -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
View File
@@ -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)
+25 -2
View File
@@ -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
View File
@@ -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,
}
}
+28 -3
View File
@@ -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
}
+42 -31
View File
@@ -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
View File
@@ -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 {
+237
View File
@@ -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
View File
@@ -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 {