From 31b2662ecf1ca323ab4965bc86faabd3105596f3 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sun, 12 Apr 2026 12:02:40 +0700 Subject: [PATCH] feat(tools): API key management in web search chain form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API keys for Exa, Tavily, and Brave can now be set directly in the web search provider chain settings dialog. Keys are saved to config_secrets (AES-256-GCM encrypted, tenant-scoped) and stripped from the persisted settings blob. Raw key values are never returned in API responses — only boolean set/unset status. Backend: - Add ConfigSecretsStore to BuiltinToolsHandler - extractAndSaveSecrets: parse api_key from settings, save to config_secrets, strip before persisting to builtin_tool_tenant_configs - getSecretsStatus: returns boolean map per tool (never raw values) - Enriched handleList response with secrets_set per tool Frontend: - Per-provider API key input in web search chain cards - "Key set" status badge when key exists, "Change" button to replace - Staged keys sent with settings on save, backend extracts --- cmd/gateway_http_handlers.go | 2 +- internal/http/builtin_tools.go | 146 +++++++++++++++++- ui/web/src/i18n/locales/en/tools.json | 6 +- ui/web/src/i18n/locales/vi/tools.json | 6 +- ui/web/src/i18n/locales/zh/tools.json | 6 +- .../builtin-tool-settings-dialog.tsx | 1 + .../builtin-tools/hooks/use-builtin-tools.ts | 3 + .../builtin-tools/web-search-chain-form.tsx | 64 ++++++-- 8 files changed, 213 insertions(+), 21 deletions(-) diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 64d93066..0d175266 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -82,7 +82,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir } if stores != nil && stores.BuiltinTools != nil { - builtinToolsH = httpapi.NewBuiltinToolsHandler(stores.BuiltinTools, stores.BuiltinToolTenantCfgs, stores.Tenants, msgBus) + builtinToolsH = httpapi.NewBuiltinToolsHandler(stores.BuiltinTools, stores.BuiltinToolTenantCfgs, stores.Tenants, stores.ConfigSecrets, msgBus) } if stores != nil && stores.PendingMessages != nil { diff --git a/internal/http/builtin_tools.go b/internal/http/builtin_tools.go index 106ddc3e..cd744446 100644 --- a/internal/http/builtin_tools.go +++ b/internal/http/builtin_tools.go @@ -1,9 +1,11 @@ package http import ( + "context" "encoding/json" "log/slog" "net/http" + "strings" "github.com/google/uuid" @@ -20,12 +22,24 @@ 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, msgBus *bus.MessageBus) *BuiltinToolsHandler { - return &BuiltinToolsHandler{store: s, tenantCfgStore: tenantCfgs, tenantStore: tenantStore, msgBus: msgBus} +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", + }, } // RegisterRoutes registers all built-in tool routes on the given mux. @@ -63,6 +77,95 @@ func (h *BuiltinToolsHandler) emitCacheInvalidate(key string, tenantID uuid.UUID }) } +// 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 { @@ -78,27 +181,43 @@ func (h *BuiltinToolsHandler) handleList(w http.ResponseWriter, r *http.Request) enabledOverrides, _ := h.tenantCfgStore.ListAll(r.Context(), tid) settingsOverrides, _ := h.tenantCfgStore.ListAllSettings(r.Context(), tid) if len(enabledOverrides) > 0 || len(settingsOverrides) > 0 { - type toolWithTenant struct { + 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([]toolWithTenant, len(result)) + enriched := make([]toolWithTenantAndSecrets, len(result)) for i, t := range result { - enriched[i] = toolWithTenant{BuiltinToolDef: t} + 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 } } - writeJSON(w, http.StatusOK, map[string]any{"tools": result}) + // 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) { @@ -145,7 +264,18 @@ func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Reques allowed["enabled"] = v } if v, ok := updates["settings"]; ok { - allowed["settings"] = v + // Extract secrets before saving settings + if settingsRaw, err := json.Marshal(v); err == nil { + 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 { @@ -274,7 +404,7 @@ func (h *BuiltinToolsHandler) handleSetTenantConfig(w http.ResponseWriter, r *ht if body.Settings != nil { var payload json.RawMessage if string(body.Settings) != "null" { - payload = body.Settings + 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) diff --git a/ui/web/src/i18n/locales/en/tools.json b/ui/web/src/i18n/locales/en/tools.json index 4b5d0a7a..310f6716 100644 --- a/ui/web/src/i18n/locales/en/tools.json +++ b/ui/web/src/i18n/locales/en/tools.json @@ -157,7 +157,11 @@ "brave": "Brave Search", "duckduckgo": "DuckDuckGo" }, - "apiKeyHint": "API keys for Exa, Tavily, and Brave are managed in Config → Secrets. Providers without a key are skipped automatically." + "apiKey": "API Key", + "apiKeySet": "✓ Key set", + "apiKeyChange": "Change", + "apiKeyPlaceholder": "Enter API key", + "apiKeyReplacePlaceholder": "Enter new key to replace" }, "ttsForm": { "title": "TTS Settings", diff --git a/ui/web/src/i18n/locales/vi/tools.json b/ui/web/src/i18n/locales/vi/tools.json index 63cc8aec..25388acf 100644 --- a/ui/web/src/i18n/locales/vi/tools.json +++ b/ui/web/src/i18n/locales/vi/tools.json @@ -157,7 +157,11 @@ "brave": "Brave Search", "duckduckgo": "DuckDuckGo" }, - "apiKeyHint": "API key cho Exa, Tavily và Brave được quản lý tại Cấu hình → Secrets. Nhà cung cấp không có key sẽ tự động bỏ qua." + "apiKey": "API Key", + "apiKeySet": "✓ Đã có key", + "apiKeyChange": "Thay đổi", + "apiKeyPlaceholder": "Nhập API key", + "apiKeyReplacePlaceholder": "Nhập key mới để thay thế" }, "ttsForm": { "title": "Cài đặt TTS", diff --git a/ui/web/src/i18n/locales/zh/tools.json b/ui/web/src/i18n/locales/zh/tools.json index 75f2e20c..88bd407b 100644 --- a/ui/web/src/i18n/locales/zh/tools.json +++ b/ui/web/src/i18n/locales/zh/tools.json @@ -71,7 +71,11 @@ "brave": "Brave Search", "duckduckgo": "DuckDuckGo" }, - "apiKeyHint": "Exa、Tavily 和 Brave 的 API Key 在 配置 → Secrets 中管理。没有 Key 的 Provider 将自动跳过。" + "apiKey": "API Key", + "apiKeySet": "✓ 已设置", + "apiKeyChange": "更换", + "apiKeyPlaceholder": "输入 API Key", + "apiKeyReplacePlaceholder": "输入新 Key 以替换" }, "ttsForm": { "title": "TTS 设置", diff --git a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx index e24bfef0..026cf74d 100644 --- a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx +++ b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx @@ -86,6 +86,7 @@ export function BuiltinToolSettingsDialog({ {isWebSearch && tool ? ( onSave(tool.name, settings).then(() => onOpenChange(false))} onCancel={() => onOpenChange(false)} /> diff --git a/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts b/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts index 0ef0b91d..23a92058 100644 --- a/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts +++ b/ui/web/src/pages/builtin-tools/hooks/use-builtin-tools.ts @@ -20,6 +20,9 @@ export interface BuiltinToolData { tenant_settings: Record | null; requires: string[]; metadata: Record; + // Boolean status map for tool API keys. Raw values are never returned. + // Key format: "tools.web..api_key". Present only for tools with secrets. + secrets_set?: Record; created_at: string; updated_at: string; } diff --git a/ui/web/src/pages/builtin-tools/web-search-chain-form.tsx b/ui/web/src/pages/builtin-tools/web-search-chain-form.tsx index 6fdf6b34..364d2515 100644 --- a/ui/web/src/pages/builtin-tools/web-search-chain-form.tsx +++ b/ui/web/src/pages/builtin-tools/web-search-chain-form.tsx @@ -32,10 +32,14 @@ interface ProviderEntry { name: ProviderKey; enabled: boolean; max_results?: number; + /** Staged API key value — extracted and saved to config_secrets on PUT, never stored in settings */ + apiKey?: string; } interface Props { initialSettings: Record; + /** Boolean status map: "tools.web..api_key" → true if a key is stored */ + secretsSet?: Record; onSave: (settings: Record) => Promise; onCancel: () => void; } @@ -58,7 +62,7 @@ function parseInitialEntries(settings: Record): ProviderEntry[] ) : DEFAULT_ORDER; - const sortable: ProviderEntry[] = rawOrder.map((name) => { + return rawOrder.map((name) => { const cfg = (settings[name] ?? {}) as Record; return { id: uniqueId(), @@ -67,21 +71,21 @@ function parseInitialEntries(settings: Record): ProviderEntry[] max_results: cfg.max_results != null ? Number(cfg.max_results) : undefined, }; }); - - return sortable; } interface SortableCardProps { entry: ProviderEntry; index: number; + secretsSet?: Record; onUpdate: (id: string, patch: Partial) => void; } -function SortableProviderCard({ entry, index, onUpdate }: SortableCardProps) { +function SortableProviderCard({ entry, index, secretsSet, onUpdate }: SortableCardProps) { const { t } = useTranslation("tools"); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.id, }); + const [showKeyInput, setShowKeyInput] = useState(false); const style = { transform: CSS.Transform.toString(transform), @@ -89,6 +93,10 @@ function SortableProviderCard({ entry, index, onUpdate }: SortableCardProps) { opacity: isDragging ? 0.5 : 1, }; + const secretKey = `tools.web.${entry.name}.api_key`; + const keyIsSet = secretsSet?.[secretKey] === true; + const showInput = showKeyInput || !keyIsSet; + const displayName = t(`builtin.searchChain.providers.${entry.name}`, { defaultValue: entry.name }); return ( @@ -116,6 +124,8 @@ function SortableProviderCard({ entry, index, onUpdate }: SortableCardProps) { /> {displayName} + + {/* Max results row */}
+ + {/* API key row */} +
+ + {keyIsSet && !showInput ? ( +
+ + {t("builtin.searchChain.apiKeySet")} + + +
+ ) : ( + onUpdate(entry.id, { apiKey: e.target.value })} + className="h-7 flex-1 text-base md:text-sm font-mono" + /> + )} +
); @@ -157,7 +202,7 @@ function LockedDuckDuckGoCard({ settings }: { settings: Record ); } -export function WebSearchChainForm({ initialSettings, onSave, onCancel }: Props) { +export function WebSearchChainForm({ initialSettings, secretsSet, onSave, onCancel }: Props) { const { t } = useTranslation("tools"); const [entries, setEntries] = useState(() => parseInitialEntries(initialSettings), @@ -192,6 +237,10 @@ export function WebSearchChainForm({ initialSettings, onSave, onCancel }: Props) for (const entry of entries) { const cfg: Record = { enabled: entry.enabled }; if (entry.max_results != null) cfg.max_results = entry.max_results; + // Include api_key only when user typed a new value — backend extracts and strips it + if (entry.apiKey && entry.apiKey.trim() !== "") { + cfg.api_key = entry.apiKey.trim(); + } settings[entry.name] = cfg; } settings[LOCKED_PROVIDER] = { enabled: true }; @@ -218,16 +267,13 @@ export function WebSearchChainForm({ initialSettings, onSave, onCancel }: Props) key={entry.id} entry={entry} index={index} + secretsSet={secretsSet} onUpdate={handleUpdate} /> ))} - -

- {t("builtin.searchChain.apiKeyHint")} -