From e096b1a671fac66210733875eca7d7b3dec8eef1 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Thu, 7 May 2026 11:34:11 +0800 Subject: [PATCH] feat(acp): HTTP handler for agent kinds (admin write / user read with field redaction) --- internal/acp/dto.go | 48 ++++++++ internal/acp/handler.go | 235 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 internal/acp/dto.go create mode 100644 internal/acp/handler.go diff --git a/internal/acp/dto.go b/internal/acp/dto.go new file mode 100644 index 0000000..9355d35 --- /dev/null +++ b/internal/acp/dto.go @@ -0,0 +1,48 @@ +package acp + +import ( + "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"` +} + +// AgentKindPublicDTO 是普通用户视图,不暴露 binary_path / args / env_keys。 +type AgentKindPublicDTO struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Enabled bool `json:"enabled"` +} + +// 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"` +} + +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"` +} diff --git a/internal/acp/handler.go b/internal/acp/handler.go new file mode 100644 index 0000000..787f3c8 --- /dev/null +++ b/internal/acp/handler.go @@ -0,0 +1,235 @@ +// Package acp handler.go exposes the ACP module's HTTP / WS endpoints. +// Part 1: agent_kinds routes only. Part 2 will add sessions REST + WS upgrader. +package acp + +import ( + "context" + "encoding/json" + "net/http" + "sort" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// AdminLookup resolves whether a user is an admin (mirrors chat/workspace pattern). +type AdminLookup interface { + IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error) +} + +// Handler exposes ACP HTTP endpoints. Part 2 will extend with SessionService and Supervisor. +type Handler struct { + akSvc AgentKindService + resolver middleware.SessionResolver + adminLookup AdminLookup + enc *crypto.Encryptor +} + +// NewHandler constructs Handler (Part 1: agent_kinds only). +func NewHandler(ak AgentKindService, resolver middleware.SessionResolver, al AdminLookup, enc *crypto.Encryptor) *Handler { + return &Handler{akSvc: ak, resolver: resolver, adminLookup: al, enc: enc} +} + +// Mount registers /api/v1/acp/* routes. +func (h *Handler) Mount(r chi.Router) { + r.Route("/api/v1/acp/agent-kinds", func(r chi.Router) { + r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) + r.Get("/", h.listAgentKinds) + r.Post("/", h.createAgentKind) + r.Get("/{id}", h.getAgentKind) + r.Patch("/{id}", h.updateAgentKind) + r.Delete("/{id}", h.deleteAgentKind) + }) +} + +func (h *Handler) caller(r *http.Request) (Caller, error) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + return Caller{}, errs.New(errs.CodeUnauthorized, "not authenticated") + } + admin, err := h.adminLookup.IsAdmin(r.Context(), uid) + if err != nil { + return Caller{}, err + } + return Caller{UserID: uid, IsAdmin: admin}, nil +} + +func (h *Handler) listAgentKinds(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + ks, err := h.akSvc.List(r.Context(), c) + if err != nil { + writeErr(w, r, err) + return + } + if c.IsAdmin { + out := make([]AgentKindAdminDTO, 0, len(ks)) + for _, k := range ks { + out = append(out, h.agentKindToAdminDTO(k)) + } + httpx.WriteJSON(w, http.StatusOK, out) + return + } + out := make([]AgentKindPublicDTO, 0, len(ks)) + for _, k := range ks { + out = append(out, agentKindToPublicDTO(k)) + } + httpx.WriteJSON(w, http.StatusOK, out) +} + +func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + var req CreateAgentKindReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json")) + return + } + enabled := true + if req.Enabled != nil { + 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, + } + out, err := h.akSvc.Create(r.Context(), c, in) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, h.agentKindToAdminDTO(out)) +} + +func (h *Handler) getAgentKind(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 + } + k, err := h.akSvc.Get(r.Context(), c, id) + if err != nil { + writeErr(w, r, err) + return + } + if c.IsAdmin { + httpx.WriteJSON(w, http.StatusOK, h.agentKindToAdminDTO(k)) + return + } + httpx.WriteJSON(w, http.StatusOK, agentKindToPublicDTO(k)) +} + +func (h *Handler) updateAgentKind(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 + } + var req UpdateAgentKindReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json")) + return + } + in := UpdateAgentKindInput{ + DisplayName: req.DisplayName, + Description: req.Description, + BinaryPath: req.BinaryPath, + Args: req.Args, + Env: req.Env, + Enabled: req.Enabled, + } + out, err := h.akSvc.Update(r.Context(), c, id, in) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, h.agentKindToAdminDTO(out)) +} + +func (h *Handler) deleteAgentKind(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 + } + if err := h.akSvc.Delete(r.Context(), c, id); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ===== DTO converters ===== + +// agentKindToAdminDTO is a Handler method because env-key extraction needs the encryptor. +func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO { + envKeys := []string{} + if len(k.EncryptedEnv) > 0 { + if env, err := decryptEnv(h.enc, k.EncryptedEnv); err == nil { + for key := range env { + envKeys = append(envKeys, key) + } + sort.Strings(envKeys) + } + } + 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"), + } +} + +func agentKindToPublicDTO(k *AgentKind) AgentKindPublicDTO { + return AgentKindPublicDTO{ + ID: k.ID, + Name: k.Name, + DisplayName: k.DisplayName, + Enabled: k.Enabled, + } +} + +// ===== transport helpers ===== + +func writeErr(w http.ResponseWriter, r *http.Request, err error) { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) +}