mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 02:14:39 +00:00
* feat(tools): add Serply as a web_search provider Serply is a SERP API returning Google web results on a tenant-supplied key. It joins the existing chain the same way Exa, Tavily and Brave do: keyed from config_secrets, skipped silently when no key is stored, and selectable through the provider parameter for cross-engine corroboration. Freshness maps onto Google's tbs recency filter, which Serply forwards upstream. Only the pd/pw/pm/py shortcuts are sent: upstream ignores the equivalent cdr date range and answers with unfiltered results, so a range leaves tbs unset rather than naming a filter the results do not honour. The settings form gained a fifth sortable provider, so the locked DuckDuckGo card now derives its position number instead of hardcoding it, and a stored provider_order is reconciled against the full provider list rather than against Parallel alone. * test(tools): use a locally named round trip stub in the serply test The serply cancellation test borrowed parallelRoundTripFunc from web_search_parallel_test.go. Define serplyRoundTripFunc alongside the test that uses it so the file does not depend on another provider's test helper.
458 lines
16 KiB
Go
458 lines
16 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// BuiltinToolsHandler handles built-in tool management endpoints.
|
|
// Built-in tools are seeded at startup; only enabled and settings are editable.
|
|
type BuiltinToolsHandler struct {
|
|
store store.BuiltinToolStore
|
|
tenantCfgStore store.BuiltinToolTenantConfigStore
|
|
tenantStore store.TenantStore
|
|
secretsStore store.ConfigSecretsStore
|
|
msgBus *bus.MessageBus
|
|
}
|
|
|
|
// NewBuiltinToolsHandler creates a handler for built-in tool management endpoints.
|
|
func NewBuiltinToolsHandler(s store.BuiltinToolStore, tenantCfgs store.BuiltinToolTenantConfigStore, tenantStore store.TenantStore, secretsStore store.ConfigSecretsStore, msgBus *bus.MessageBus) *BuiltinToolsHandler {
|
|
return &BuiltinToolsHandler{store: s, tenantCfgStore: tenantCfgs, tenantStore: tenantStore, secretsStore: secretsStore, msgBus: msgBus}
|
|
}
|
|
|
|
// toolSecretKeys maps (tool_name, settings field path) → config_secrets key.
|
|
// When saving settings, if a settings blob contains these fields, they are
|
|
// extracted, saved to config_secrets, and stripped from the persisted settings.
|
|
var toolSecretKeys = map[string]map[string]string{
|
|
"web_search": {
|
|
"exa.api_key": "tools.web.exa.api_key",
|
|
"tavily.api_key": "tools.web.tavily.api_key",
|
|
"brave.api_key": "tools.web.brave.api_key",
|
|
"serply.api_key": "tools.web.serply.api_key",
|
|
},
|
|
}
|
|
|
|
// RegisterRoutes registers all built-in tool routes on the given mux.
|
|
func (h *BuiltinToolsHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
// Builtin tools (reads: viewer+, writes: admin+)
|
|
mux.HandleFunc("GET /v1/tools/builtin", h.auth(h.handleList))
|
|
mux.HandleFunc("GET /v1/tools/builtin/{name}", h.auth(h.handleGet))
|
|
mux.HandleFunc("PUT /v1/tools/builtin/{name}", h.adminAuth(h.handleUpdate))
|
|
mux.HandleFunc("GET /v1/tools/builtin/{name}/tenant-config", h.adminAuth(h.handleGetTenantConfig))
|
|
mux.HandleFunc("PUT /v1/tools/builtin/{name}/tenant-config", h.adminAuth(h.handleSetTenantConfig))
|
|
mux.HandleFunc("DELETE /v1/tools/builtin/{name}/tenant-config", h.adminAuth(h.handleDeleteTenantConfig))
|
|
}
|
|
|
|
func (h *BuiltinToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth("", next)
|
|
}
|
|
|
|
func (h *BuiltinToolsHandler) adminAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth(permissions.RoleAdmin, next)
|
|
}
|
|
|
|
// emitCacheInvalidate broadcasts a cache invalidation event for a builtin tool.
|
|
// tenantID == uuid.Nil means global invalidation (master admin path).
|
|
func (h *BuiltinToolsHandler) emitCacheInvalidate(key string, tenantID uuid.UUID) {
|
|
if h.msgBus == nil {
|
|
return
|
|
}
|
|
h.msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventCacheInvalidate,
|
|
Payload: bus.CacheInvalidatePayload{
|
|
Kind: bus.CacheKindBuiltinTools,
|
|
Key: key,
|
|
TenantID: tenantID,
|
|
},
|
|
})
|
|
}
|
|
|
|
// extractAndSaveSecrets extracts secret fields from a tool settings blob,
|
|
// saves them to config_secrets, and returns the cleaned settings with
|
|
// secrets stripped. Secret fields are identified by toolSecretKeys.
|
|
func (h *BuiltinToolsHandler) extractAndSaveSecrets(ctx context.Context, toolName string, raw json.RawMessage) json.RawMessage {
|
|
if h.secretsStore == nil {
|
|
return raw
|
|
}
|
|
mapping, ok := toolSecretKeys[toolName]
|
|
if !ok || len(raw) == 0 {
|
|
return raw
|
|
}
|
|
|
|
var settings map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &settings); err != nil {
|
|
return raw
|
|
}
|
|
|
|
modified := false
|
|
for settingsPath, secretKey := range mapping {
|
|
parts := strings.SplitN(settingsPath, ".", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
section, field := parts[0], parts[1]
|
|
|
|
sectionRaw, ok := settings[section]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
var sectionMap map[string]json.RawMessage
|
|
if err := json.Unmarshal(sectionRaw, §ionMap); err != nil {
|
|
continue
|
|
}
|
|
|
|
keyRaw, ok := sectionMap[field]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
var keyValue string
|
|
if err := json.Unmarshal(keyRaw, &keyValue); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Strip field regardless; save only non-empty, non-masked values
|
|
delete(sectionMap, field)
|
|
if rebuilt, err := json.Marshal(sectionMap); err == nil {
|
|
settings[section] = rebuilt
|
|
}
|
|
modified = true
|
|
|
|
if keyValue == "" || keyValue == "***" {
|
|
continue
|
|
}
|
|
|
|
if err := h.secretsStore.Set(ctx, secretKey, keyValue); err != nil {
|
|
slog.Warn("failed to save tool secret", "tool", toolName, "key", secretKey, "error", err)
|
|
}
|
|
}
|
|
|
|
if !modified {
|
|
return raw
|
|
}
|
|
cleaned, err := json.Marshal(settings)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
// getSecretsStatus returns which secret keys are set for a tool (boolean only, never raw values).
|
|
func (h *BuiltinToolsHandler) getSecretsStatus(ctx context.Context, toolName string) map[string]bool {
|
|
if h.secretsStore == nil {
|
|
return nil
|
|
}
|
|
mapping, ok := toolSecretKeys[toolName]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
status := make(map[string]bool, len(mapping))
|
|
for _, secretKey := range mapping {
|
|
val, err := h.secretsStore.Get(ctx, secretKey)
|
|
status[secretKey] = err == nil && val != ""
|
|
}
|
|
return status
|
|
}
|
|
|
|
func (h *BuiltinToolsHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
|
result, err := h.store.List(r.Context())
|
|
if err != nil {
|
|
slog.Error("builtin_tools.list", "error", err)
|
|
locale := extractLocale(r)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")})
|
|
return
|
|
}
|
|
|
|
// Merge per-tenant overrides (enabled + settings) into response when tenant-scoped.
|
|
tid := store.TenantIDFromContext(r.Context())
|
|
if tid != uuid.Nil && h.tenantCfgStore != nil {
|
|
enabledOverrides, _ := h.tenantCfgStore.ListAll(r.Context(), tid)
|
|
settingsOverrides, _ := h.tenantCfgStore.ListAllSettings(r.Context(), tid)
|
|
if len(enabledOverrides) > 0 || len(settingsOverrides) > 0 {
|
|
type toolWithTenantAndSecrets struct {
|
|
store.BuiltinToolDef
|
|
TenantEnabled *bool `json:"tenant_enabled"`
|
|
TenantSettings json.RawMessage `json:"tenant_settings,omitempty"`
|
|
SecretsSet map[string]bool `json:"secrets_set,omitempty"`
|
|
}
|
|
enriched := make([]toolWithTenantAndSecrets, len(result))
|
|
for i, t := range result {
|
|
enriched[i] = toolWithTenantAndSecrets{BuiltinToolDef: t}
|
|
if enabled, ok := enabledOverrides[t.Name]; ok {
|
|
enriched[i].TenantEnabled = &enabled
|
|
}
|
|
if raw, ok := settingsOverrides[t.Name]; ok {
|
|
enriched[i].TenantSettings = raw
|
|
}
|
|
if _, hasSecrets := toolSecretKeys[t.Name]; hasSecrets {
|
|
enriched[i].SecretsSet = h.getSecretsStatus(r.Context(), t.Name)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"tools": enriched})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Non-tenant path: include secrets_set for tools that have secrets
|
|
type toolWithSecrets struct {
|
|
store.BuiltinToolDef
|
|
SecretsSet map[string]bool `json:"secrets_set,omitempty"`
|
|
}
|
|
enriched := make([]toolWithSecrets, len(result))
|
|
for i, t := range result {
|
|
enriched[i] = toolWithSecrets{BuiltinToolDef: t}
|
|
if _, hasSecrets := toolSecretKeys[t.Name]; hasSecrets {
|
|
enriched[i].SecretsSet = h.getSecretsStatus(r.Context(), t.Name)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"tools": enriched})
|
|
}
|
|
|
|
func (h *BuiltinToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
name := r.PathValue("name")
|
|
if name == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
|
|
return
|
|
}
|
|
|
|
def, err := h.store.Get(r.Context(), name)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "tool", name)})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, def)
|
|
}
|
|
|
|
func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
|
// Phase 0b hotfix: builtin_tools is a global table with no tenant_id column,
|
|
// so this write must be restricted to master-scope callers. Non-master tenant
|
|
// admins must go through PUT /v1/tools/builtin/{name}/tenant-config instead.
|
|
// See plans/reports/debugger-260412-0922-tenant-scope-audit.md CRITICAL-1.
|
|
if !requireMasterScope(w, r) {
|
|
return
|
|
}
|
|
locale := extractLocale(r)
|
|
name := r.PathValue("name")
|
|
if name == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
|
|
return
|
|
}
|
|
|
|
var updates map[string]any
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
|
return
|
|
}
|
|
|
|
// Only allow enabled and settings to be updated
|
|
allowed := make(map[string]any)
|
|
if v, ok := updates["enabled"]; ok {
|
|
allowed["enabled"] = v
|
|
}
|
|
if v, ok := updates["settings"]; ok {
|
|
// Extract secrets before saving settings
|
|
if settingsRaw, err := json.Marshal(v); err == nil {
|
|
if err := tools.ValidateExecSettingsJSON(name, settingsRaw, false); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
cleaned := h.extractAndSaveSecrets(r.Context(), name, settingsRaw)
|
|
var cleanedMap any
|
|
if err2 := json.Unmarshal(cleaned, &cleanedMap); err2 == nil {
|
|
allowed["settings"] = cleanedMap
|
|
} else {
|
|
allowed["settings"] = v
|
|
}
|
|
} else {
|
|
allowed["settings"] = v
|
|
}
|
|
}
|
|
|
|
if len(allowed) == 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidUpdates)})
|
|
return
|
|
}
|
|
|
|
if err := h.store.Update(r.Context(), name, allowed); err != nil {
|
|
slog.Error("builtin_tools.update", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
emitAudit(h.msgBus, r, "builtin_tool.updated", "builtin_tool", name)
|
|
h.emitCacheInvalidate(name, uuid.Nil)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
|
}
|
|
|
|
// setTenantConfigRequest is the PUT body for tenant config overrides.
|
|
// Both fields are optional — at least one must be set. Pointer *bool
|
|
// distinguishes "not set" (pass-through) from "explicit false". json.RawMessage
|
|
// for settings preserves bytes for the store without an intermediate decode.
|
|
type setTenantConfigRequest struct {
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
Settings json.RawMessage `json:"settings,omitempty"`
|
|
}
|
|
|
|
// isValidSettingsJSON returns true if raw is a JSON object or the literal "null".
|
|
// Non-object types (arrays, primitives) are rejected to keep the schema predictable.
|
|
func isValidSettingsJSON(raw json.RawMessage) bool {
|
|
s := string(raw)
|
|
if s == "null" {
|
|
return true
|
|
}
|
|
var v map[string]any
|
|
return json.Unmarshal(raw, &v) == nil
|
|
}
|
|
|
|
// handleGetTenantConfig returns the tenant override view for a single tool.
|
|
// Response: { tool_name, enabled, settings } — enabled/settings nil when unset.
|
|
func (h *BuiltinToolsHandler) handleGetTenantConfig(w http.ResponseWriter, r *http.Request) {
|
|
if h.tenantCfgStore == nil {
|
|
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"})
|
|
return
|
|
}
|
|
if !requireTenantAdmin(w, r, h.tenantStore) {
|
|
return
|
|
}
|
|
name := r.PathValue("name")
|
|
tid := store.TenantIDFromContext(r.Context())
|
|
if tid == uuid.Nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"})
|
|
return
|
|
}
|
|
|
|
enabledAll, listErr := h.tenantCfgStore.ListAll(r.Context(), tid)
|
|
if listErr != nil {
|
|
slog.Warn("list tenant enabled overrides failed", "tenant", tid, "error", listErr)
|
|
}
|
|
settings, err := h.tenantCfgStore.GetSettings(r.Context(), tid, name)
|
|
if err != nil {
|
|
slog.Warn("get tenant tool settings failed", "tool", name, "tenant", tid, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
type response struct {
|
|
ToolName string `json:"tool_name"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
Settings json.RawMessage `json:"settings,omitempty"`
|
|
}
|
|
resp := response{ToolName: name, Settings: settings}
|
|
if v, ok := enabledAll[name]; ok {
|
|
resp.Enabled = &v
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleSetTenantConfig upserts per-tenant overrides for a builtin tool.
|
|
// Body: { enabled?, settings? } — both optional, at least one required.
|
|
// Settings passed as literal `null` clears the settings column without deleting the row.
|
|
func (h *BuiltinToolsHandler) handleSetTenantConfig(w http.ResponseWriter, r *http.Request) {
|
|
if h.tenantCfgStore == nil {
|
|
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"})
|
|
return
|
|
}
|
|
if !requireTenantAdmin(w, r, h.tenantStore) {
|
|
return
|
|
}
|
|
name := r.PathValue("name")
|
|
tid := store.TenantIDFromContext(r.Context())
|
|
if tid == uuid.Nil {
|
|
// Defense-in-depth: owner-role bypass in requireTenantAdmin could
|
|
// otherwise reach here without a tenant scope. A nil tid must never
|
|
// flow into the cache invalidate emit as a global wipe.
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"})
|
|
return
|
|
}
|
|
|
|
var body setTenantConfigRequest
|
|
// 16KB cap — settings blobs should stay small (provider chains, toggles).
|
|
// Large blobs indicate misuse; reject to prevent trivial DoS via oversized JSON.
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
if body.Enabled == nil && body.Settings == nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one of enabled or settings required"})
|
|
return
|
|
}
|
|
if body.Settings != nil && !isValidSettingsJSON(body.Settings) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "settings must be a JSON object or null"})
|
|
return
|
|
}
|
|
if body.Settings != nil {
|
|
if err := tools.ValidateExecSettingsJSON(name, body.Settings, true); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Write enabled if provided (preserves settings column via column-list upsert).
|
|
if body.Enabled != nil {
|
|
if err := h.tenantCfgStore.Set(r.Context(), tid, name, *body.Enabled); err != nil {
|
|
slog.Warn("set tenant tool enabled failed", "tool", name, "tenant", tid, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
// Write settings if provided (preserves enabled column). JSON literal `null`
|
|
// maps to Go nil RawMessage after decode — pass through to store which writes SQL NULL.
|
|
if body.Settings != nil {
|
|
var payload json.RawMessage
|
|
if string(body.Settings) != "null" {
|
|
payload = h.extractAndSaveSecrets(r.Context(), name, body.Settings)
|
|
}
|
|
if err := h.tenantCfgStore.SetSettings(r.Context(), tid, name, payload); err != nil {
|
|
slog.Warn("set tenant tool settings failed", "tool", name, "tenant", tid, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
|
|
emitAudit(h.msgBus, r, "builtin_tool.tenant_config.set", "builtin_tool", name)
|
|
h.emitCacheInvalidate(name, tid)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
|
}
|
|
|
|
// handleDeleteTenantConfig removes a per-tenant override (reverts to default).
|
|
func (h *BuiltinToolsHandler) handleDeleteTenantConfig(w http.ResponseWriter, r *http.Request) {
|
|
if h.tenantCfgStore == nil {
|
|
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"})
|
|
return
|
|
}
|
|
if !requireTenantAdmin(w, r, h.tenantStore) {
|
|
return
|
|
}
|
|
name := r.PathValue("name")
|
|
tid := store.TenantIDFromContext(r.Context())
|
|
if tid == uuid.Nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"})
|
|
return
|
|
}
|
|
|
|
if err := h.tenantCfgStore.Delete(r.Context(), tid, name); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
emitAudit(h.msgBus, r, "builtin_tool.tenant_config.deleted", "builtin_tool", name)
|
|
h.emitCacheInvalidate(name, tid)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|