feat(captain): validate custom tools

This commit is contained in:
2026-06-07 16:20:39 +08:00
parent dd7bcf4118
commit 8f9adecd04
5 changed files with 213 additions and 12 deletions
+7 -4
View File
@@ -49,11 +49,11 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-07 P3.105 Captain custom tool create limits and slug parity, prepared as `feat(captain): align custom tool limits`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(captain): align custom tool limits`.
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot-compatible Captain custom-tool create caps and generated slugs.
- Current tracking checkpoint: 2026-06-07 P3.106 Captain custom tool validation parity, prepared as `feat(captain): validate custom tools`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(captain): validate custom tools`.
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot-compatible Captain custom-tool enum and parameter-schema validation.
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
- Worktree status at this implementation checkpoint: Captain custom-tool create now enforces Chatwoot's 15-tools-per-account cap, returns raw `422 { error: "You can create a maximum of 15 custom tools per account" }` when exceeded, generates default `custom_` underscore slugs capped at 64 characters, appends six-character lowercase alphanumeric collision suffixes, and keeps custom-tool uniqueness scoped by account like the reference schema. This retains P3.104 Captain custom tool feature gating, P5.3i CSAT survey indexing, P5.3h provider webhook indexing, P5.3g message delivery indexing, P5.3f automation action indexing, P5.3e conversation maintenance indexing, P5.3d conversation bulk-action indexing, P5.3c contact label search indexing, P3.103 contact bulk-action parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Worktree status at this implementation checkpoint: Captain custom-tool create/update now validates persisted fields closer to `Captain::CustomTool`: blank title/endpoint URL, explicit duplicate slug, unsupported `http_method`/`auth_type`, generated slug length, and `param_schema` item shape return raw `422 { message, attributes }` record-invalid responses instead of generic 500s. This retains P3.105 custom-tool create limits and slug parity, P3.104 Captain custom tool feature gating, P5.3i CSAT survey indexing, P5.3h provider webhook indexing, P5.3g message delivery indexing, P5.3f automation action indexing, P5.3e conversation maintenance indexing, P5.3d conversation bulk-action indexing, P5.3c contact label search indexing, P3.103 contact bulk-action parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes.
- `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full.
- Route dump succeeds with `972` registered routes after enterprise account route tracking.
@@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 0 | P3.106 Captain custom tool validation parity | Implemented for reused Captain custom-tool create/update validation: GoChat now maps blank title/endpoint URL, explicit duplicate slug, unsupported `GET`/`POST`-only `http_method`, unsupported `none`/`bearer`/`basic`/`api_key` `auth_type`, generated slug length overflow, and invalid `param_schema` array item shape into Chatwoot-style raw `422 { message, attributes }` responses. `param_schema` items require string `name`, `type`, and `description`, allow boolean `required`, and reject extra keys. | Keep in Review; reopen from B12 Captain custom-tools smoke or fresh reference evidence for exact Rails `JSONSchemer` additional-property wording, title max-length frontend-only enforcement, update clearing semantics for blank fields, or admin-only `auth_config` serializer gating. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| 0 | P3.105 Captain custom tool create limits and slug parity | Implemented for reused Captain custom-tool creation: GoChat now mirrors `Captain::CustomTool` by allowing at most 15 custom tools per account, returning raw `422 { error: "You can create a maximum of 15 custom tools per account" }` when the cap is exceeded, generating default slugs as `custom_` + parameterized title with underscore separators, capping generated slugs at 64 characters, adding `_xxxxxx` lowercase alphanumeric suffixes on same-account collisions, and keeping the uniqueness boundary scoped to `(account_id, slug)` like the Chatwoot schema. | Keep in Review; reopen from B12 Captain custom-tools smoke or fresh reference evidence for exact Rails `parameterize` locale transliteration, concurrent account-row locking, auth-config administrator-only serialization, enum/schema validation response shape, or slug-generation exhaustion wording beyond the inspected model/controller contract. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| 0 | P3.104 Captain custom tools feature gate | Implemented for reused Captain custom-tool screens: `GET/POST/PUT/DELETE /api/v1/accounts/:account_id/captain/custom_tools` and `POST /captain/custom_tools/test` now enforce the inspected Chatwoot `ensure_custom_tools_enabled` gate. Accounts must have `custom_tools` or `captain_integration_v2` enabled; disabled accounts receive raw `403 { error: "Custom tools are not enabled for this account" }`. Bootstrap passes the account repository into the custom-tool service so normal app routes use persisted account feature flags. | Keep in Review; reopen from B12 Captain custom-tools smoke or fresh reference evidence for exact authorization ordering, feature-flag defaults, custom-tool limit behavior, or request execution drift beyond the inspected controller/frontend contract. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| 0 | P5.3i CSAT survey search indexing | Implemented for Meilisearch CSAT parity: `CsatSurveyService` reindexes CSAT survey messages and parent conversations after resolved-conversation survey creation and public survey submission; `CsatSurveyListener` propagates the durable search indexer into synchronous and queued `csat:survey_send` paths and reindexes message/conversation documents after `message.updated` CSAT submitted-value processing; bootstrap wires both the public CSAT handler service and event listener to the durable search indexer. | Keep in Review; reopen from live Meilisearch gate, CSAT smoke, or a fresh CSAT mutation path that changes searchable message/conversation fields without indexing. | Focused CSAT survey service/listener search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
@@ -401,6 +402,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(captain): validate custom tools` | Advances P3.106 with Chatwoot `Captain::CustomTool` model validation parity. GoChat now returns record-invalid-shaped raw `422 { message, attributes }` responses for blank required fields, explicit duplicate slugs, unsupported custom-tool enums, generated slug length overflow, and invalid `param_schema` items instead of generic internal errors. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.106 to Review; continue admin-only custom-tool auth serialization, Phase 2/3 audit, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(captain): align custom tool limits` | Advances P3.105 with Chatwoot Captain custom-tool create limits and generated slug parity. GoChat now enforces the 15-tools-per-account cap, returns the inspected limit error as raw `422`, generates default `custom_` underscore slugs capped at 64 characters, adds six-character lowercase alphanumeric collision suffixes, and makes the GORM model uniqueness boundary match the reference `(account_id, slug)` schema. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.105 to Review; continue Captain custom-tool validation/administrator serialization drift, Phase 2/3 audit, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(search): index conversation maintenance` | Advances P5.3/B6 for mandatory Meilisearch background-maintenance parity. Scheduled snooze reopen and account auto-resolution now reindex updated conversations, message-status maintenance reindexes changed messages plus the parent conversation, and bootstrap registers search-aware maintenance handlers after durable search indexing is wired. | Focused conversation maintenance search tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P5.3e to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(search): index conversation bulk actions` | Advances P5.3/B6 for mandatory Meilisearch conversation bulk-action parity. Account conversation bulk-action worker replay now reindexes every updated conversation after bulk status/team/assignee/snooze/label changes, while the app registers the search-aware runner after durable search indexing is wired. | Focused conversation bulk-action search test passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P5.3d to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
@@ -2817,3 +2819,4 @@ Verification milestone gates:
- 2026-06-07: P5.3i CSAT survey search-index checkpoint prepared as `feat(search): index csat surveys`; audited GoChat CSAT survey creation/submission mutation paths against the P5.3 Meilisearch durability contract. GoChat now reindexes CSAT survey messages and parent conversations after resolved-conversation survey sends, durable `csat:survey_send` replay, public CSAT survey submissions, and `message.updated` submitted-value processing; bootstrap wires the public CSAT survey service and CSAT event listener to the durable search indexer. Focused CSAT survey service/listener search tests passed; full `go test ./...` passed outside the restricted socket sandbox after the expected sandbox-only local socket failures; `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.104 Captain custom-tools feature-gate checkpoint prepared as `feat(captain): gate custom tools`; audited Chatwoot `Api::V1::Accounts::Captain::CustomToolsController#ensure_custom_tools_enabled`, reused dashboard `api/captain/customTools.js`, and GoChat Captain custom-tool routes. GoChat now gates custom-tool index/show/create/update/delete/test routes on persisted account `custom_tools` or `captain_integration_v2` feature flags, returns raw `403 { error: "Custom tools are not enabled for this account" }` for disabled accounts, and wires the account repository into the production Captain custom-tool service. Focused Captain custom-tool/resource handler tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.105 Captain custom-tool create limits and slug checkpoint prepared as `feat(captain): align custom tool limits`; audited Chatwoot `Captain::CustomTool` constants/callbacks, `CustomToolsController#create` limit rescue, and `_custom_tool.json.jbuilder`. GoChat now enforces the 15-tools-per-account cap, returns raw `422` with the reference limit message, generates omitted slugs as `custom_` + underscore-parameterized title, caps generated slugs at 64 characters, appends `_xxxxxx` lowercase alphanumeric suffixes on account-scoped collisions, and aligns the GORM uniqueness tag with the reference `(account_id, slug)` index. Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.106 Captain custom-tool validation checkpoint prepared as `feat(captain): validate custom tools`; audited Chatwoot `Captain::CustomTool` model validations, enum lists, `PARAM_SCHEMA_VALIDATION`, `JsonSchemaValidator`, and `RequestExceptionHandler#render_record_invalid`. GoChat now returns raw `422 { message, attributes }` for blank title/endpoint URL, duplicate explicit slugs, unsupported `http_method`/`auth_type`, overlong generated slugs, and invalid `param_schema` items; schema items require string `name`/`type`/`description`, allow boolean `required`, and reject extra keys. Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
@@ -246,6 +246,67 @@ func (s *CaptainCustomToolCRUDTestSuite) TestCreate_超过每账户15个工具
assert.Equal(s.T(), "You can create a maximum of 15 custom tools per account", resp["error"])
}
func (s *CaptainCustomToolCRUDTestSuite) TestCreate_无效枚举返回RecordInvalid形态422() {
body := map[string]interface{}{
"custom_tool": map[string]interface{}{
"title": "Invalid enums",
"endpoint_url": "https://example.com/invalid",
"http_method": "DELETE",
"auth_type": "oauth",
},
}
w := s.makeRequest("POST", s.accountPath()+"/captain/custom_tools/", body)
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.Contains(s.T(), resp["message"], "Http method is not included in the list")
assert.Contains(s.T(), resp["message"], "Auth type is not included in the list")
assert.ElementsMatch(s.T(), []interface{}{"http_method", "auth_type"}, resp["attributes"].([]interface{}))
}
func (s *CaptainCustomToolCRUDTestSuite) TestCreate_无效ParamSchema返回RecordInvalid形态422() {
body := map[string]interface{}{
"custom_tool": map[string]interface{}{
"title": "Invalid schema",
"endpoint_url": "https://example.com/schema",
"param_schema": []map[string]interface{}{{
"name": "order_id",
"type": "string",
"extra": "not allowed",
}},
},
}
w := s.makeRequest("POST", s.accountPath()+"/captain/custom_tools/", body)
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.Contains(s.T(), resp["message"], "Description is required")
assert.Contains(s.T(), resp["message"], "Extra is not permitted")
assert.ElementsMatch(s.T(), []interface{}{"description", "extra"}, resp["attributes"].([]interface{}))
}
func (s *CaptainCustomToolCRUDTestSuite) TestCreate_重复显式Slug返回422() {
_ = s.createToolAndGetID("First duplicate", "explicit-duplicate", "https://example.com/first")
w := s.makeRequest("POST", s.accountPath()+"/captain/custom_tools/", map[string]interface{}{
"custom_tool": map[string]interface{}{
"title": "Second duplicate",
"slug": "explicit-duplicate",
"endpoint_url": "https://example.com/second",
},
})
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(s.T(), "Slug has already been taken", resp["message"])
assert.ElementsMatch(s.T(), []interface{}{"slug"}, resp["attributes"].([]interface{}))
}
func (s *CaptainCustomToolCRUDTestSuite) TestCreate_无效JSON返回400() {
// ShouldBindJSON 在 JSON 解析失败时返回 400
w := httptest.NewRecorder()
@@ -47,6 +47,9 @@ func (h *CaptainCustomToolHandler) Create(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": service.ErrCaptainCustomToolLimitExceeded.Error()})
return
}
if renderCaptainCustomToolValidationError(c, err) {
return
}
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create custom tool")
return
}
@@ -107,6 +110,9 @@ func (h *CaptainCustomToolHandler) Update(c *gin.Context) {
tool, err := h.svc.UpdateByAccount(c.Request.Context(), accountID, id, &req)
if err != nil {
applogger.L().Errorf("Update captain custom tool: %v", err)
if renderCaptainCustomToolValidationError(c, err) {
return
}
handleServiceError(c, err)
return
}
@@ -227,6 +233,18 @@ func (h *CaptainCustomToolHandler) ensureCustomToolsEnabled(c *gin.Context, acco
return false
}
func renderCaptainCustomToolValidationError(c *gin.Context, err error) bool {
var validationErr *service.CaptainCustomToolValidationError
if !errors.As(err, &validationErr) {
return false
}
c.JSON(http.StatusUnprocessableEntity, gin.H{
"message": validationErr.Message,
"attributes": validationErr.Attributes,
})
return true
}
func captainCustomToolPayload(tool *model.CaptainCustomTool) gin.H {
return gin.H{
"id": tool.ID,
@@ -187,7 +187,7 @@ func TestCaptainCustomToolHandler_ChatwootToolPayloadsAndScope(t *testing.T) {
"endpoint_url": "https://example.com/orders",
"http_method": "POST",
"auth_type": "none",
"param_schema": []map[string]any{{"name": "order_id", "type": "string", "required": true}},
"param_schema": []map[string]any{{"name": "order_id", "type": "string", "description": "Order ID", "required": true}},
"request_template": "{\"id\":\"{{.order_id}}\"}",
}}
w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", body)
+126 -7
View File
@@ -36,6 +36,15 @@ const (
var ErrCaptainCustomToolLimitExceeded = errors.New("You can create a maximum of 15 custom tools per account")
type CaptainCustomToolValidationError struct {
Message string
Attributes []string
}
func (e *CaptainCustomToolValidationError) Error() string {
return e.Message
}
type HTTPDoer interface {
Do(req *http.Request) (*http.Response, error)
}
@@ -100,13 +109,7 @@ type UpdateCustomToolRequest struct {
// Create creates a new CaptainCustomTool.
func (s *CaptainCustomToolService) Create(ctx context.Context, accountID uint, req *CreateCustomToolRequest) (*model.CaptainCustomTool, error) {
count, err := s.toolRepo.CountByAccount(ctx, accountID)
if err != nil {
return nil, fmt.Errorf("count custom tools: %w", err)
}
if count >= maxCaptainCustomToolsPerAccount {
return nil, ErrCaptainCustomToolLimitExceeded
}
var err error
// Default values
httpMethod := req.HTTPMethod
@@ -139,6 +142,19 @@ func (s *CaptainCustomToolService) Create(ctx context.Context, accountID uint, r
ResponseTemplate: req.ResponseTemplate,
Enabled: true,
}
if err := validateCaptainCustomTool(tool); err != nil {
return nil, err
}
if req.Slug != "" && s.customToolSlugExists(ctx, accountID, req.Slug) {
return nil, newCaptainCustomToolValidationError("Slug has already been taken", "slug")
}
count, err := s.toolRepo.CountByAccount(ctx, accountID)
if err != nil {
return nil, fmt.Errorf("count custom tools: %w", err)
}
if count >= maxCaptainCustomToolsPerAccount {
return nil, ErrCaptainCustomToolLimitExceeded
}
if err := s.toolRepo.Create(ctx, tool); err != nil {
applogger.L().Errorf("Create captain custom tool: %v", err)
@@ -217,6 +233,9 @@ func (s *CaptainCustomToolService) UpdateByAccount(ctx context.Context, accountI
return nil, fmt.Errorf("custom tool not found: %w", err)
}
applyCustomToolUpdate(tool, req)
if err := validateCaptainCustomTool(tool); err != nil {
return nil, err
}
if err := s.toolRepo.Update(ctx, tool); err != nil {
applogger.L().Errorf("Update captain custom tool: %v", err)
return nil, fmt.Errorf("update custom tool: %w", err)
@@ -308,6 +327,106 @@ func (s *CaptainCustomToolService) customToolSlugExists(ctx context.Context, acc
return err == nil
}
func validateCaptainCustomTool(tool *model.CaptainCustomTool) error {
var messages []string
var attributes []string
add := func(attr, message string) {
messages = append(messages, message)
attributes = append(attributes, attr)
}
if strings.TrimSpace(tool.Title) == "" {
add("title", "Title can't be blank")
}
if strings.TrimSpace(tool.EndpointURL) == "" {
add("endpoint_url", "Endpoint url can't be blank")
}
if len(tool.Slug) > maxCaptainCustomToolSlugLength {
add("slug", "Slug is too long (maximum is 64 characters)")
}
if tool.HTTPMethod != "GET" && tool.HTTPMethod != "POST" {
add("http_method", "Http method is not included in the list")
}
switch tool.AuthType {
case model.ToolAuthTypeNone, model.ToolAuthTypeBearer, model.ToolAuthTypeBasic, model.ToolAuthTypeApiKey:
default:
add("auth_type", "Auth type is not included in the list")
}
for _, validation := range validateCaptainCustomToolParamSchema(tool.ParamSchema) {
add(validation.attribute, validation.message)
}
if len(messages) == 0 {
return nil
}
return &CaptainCustomToolValidationError{Message: strings.Join(messages, ", "), Attributes: uniqueStrings(attributes)}
}
type customToolParamSchemaValidation struct {
attribute string
message string
}
func validateCaptainCustomToolParamSchema(raw json.RawMessage) []customToolParamSchemaValidation {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var items []map[string]any
if err := json.Unmarshal(raw, &items); err != nil {
return []customToolParamSchemaValidation{{attribute: "param_schema", message: "Param schema must be of type array"}}
}
allowed := map[string]bool{"name": true, "type": true, "description": true, "required": true}
var validations []customToolParamSchemaValidation
for _, item := range items {
for _, field := range []string{"name", "type", "description"} {
value, ok := item[field]
if !ok {
validations = append(validations, customToolParamSchemaValidation{attribute: field, message: customToolFieldLabel(field) + " is required"})
continue
}
if _, ok := value.(string); !ok {
validations = append(validations, customToolParamSchemaValidation{attribute: field, message: customToolFieldLabel(field) + " must be of type string"})
}
}
if value, ok := item["required"]; ok {
if _, ok := value.(bool); !ok {
validations = append(validations, customToolParamSchemaValidation{attribute: "required", message: "Required must be of type boolean"})
}
}
for field := range item {
if !allowed[field] {
validations = append(validations, customToolParamSchemaValidation{attribute: field, message: customToolFieldLabel(field) + " is not permitted"})
}
}
}
return validations
}
func customToolFieldLabel(field string) string {
if field == "" {
return field
}
return strings.ToUpper(field[:1]) + field[1:]
}
func newCaptainCustomToolValidationError(message, attribute string) error {
return &CaptainCustomToolValidationError{Message: message, Attributes: []string{attribute}}
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
unique := make([]string, 0, len(values))
for _, value := range values {
if seen[value] {
continue
}
seen[value] = true
unique = append(unique, value)
}
return unique
}
func customToolSlug(title string) string {
slug := strings.ToLower(strings.TrimSpace(title))
slug = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(slug, "_")