feat(tools): API key management in web search chain form

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
This commit is contained in:
viettranx committed 2026-04-12 12:02:40 +07:00
1 parent 81cd6ef24b
commit 31b2662ecf
8 files changed
+213 -21

No files matched your search

+1 -1
View File
@@ -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 {
+138 -8
View File
@@ -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, &sectionMap); 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)
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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 设置",
@@ -86,6 +86,7 @@ export function BuiltinToolSettingsDialog({
{isWebSearch && tool ? (
<WebSearchChainForm
initialSettings={initialSettings}
secretsSet={tool.secrets_set}
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
onCancel={() => onOpenChange(false)}
/>
@@ -20,6 +20,9 @@ export interface BuiltinToolData {
tenant_settings: Record<string, unknown> | null;
requires: string[];
metadata: Record<string, unknown>;
// Boolean status map for tool API keys. Raw values are never returned.
// Key format: "tools.web.<provider>.api_key". Present only for tools with secrets.
secrets_set?: Record<string, boolean>;
created_at: string;
updated_at: string;
}
@@ -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<string, unknown>;
/** Boolean status map: "tools.web.<provider>.api_key" → true if a key is stored */
secretsSet?: Record<string, boolean>;
onSave: (settings: Record<string, unknown>) => Promise<void>;
onCancel: () => void;
}
@@ -58,7 +62,7 @@ function parseInitialEntries(settings: Record<string, unknown>): ProviderEntry[]
)
: DEFAULT_ORDER;
const sortable: ProviderEntry[] = rawOrder.map((name) => {
return rawOrder.map((name) => {
const cfg = (settings[name] ?? {}) as Record<string, unknown>;
return {
id: uniqueId(),
@@ -67,21 +71,21 @@ function parseInitialEntries(settings: Record<string, unknown>): ProviderEntry[]
max_results: cfg.max_results != null ? Number(cfg.max_results) : undefined,
};
});
return sortable;
}
interface SortableCardProps {
entry: ProviderEntry;
index: number;
secretsSet?: Record<string, boolean>;
onUpdate: (id: string, patch: Partial<ProviderEntry>) => 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) {
/>
<span className="text-sm font-medium flex-1">{displayName}</span>
</div>
{/* Max results row */}
<div className="flex items-center gap-1.5 mt-2 pl-10">
<Label className="text-xs text-muted-foreground whitespace-nowrap">
{t("builtin.searchChain.maxResults")}
@@ -129,6 +139,41 @@ function SortableProviderCard({ entry, index, onUpdate }: SortableCardProps) {
className="h-7 w-16 text-base md:text-sm"
/>
</div>
{/* API key row */}
<div className="flex items-center gap-1.5 mt-2 pl-10">
<Label className="text-xs text-muted-foreground whitespace-nowrap">
{t("builtin.searchChain.apiKey")}
</Label>
{keyIsSet && !showInput ? (
<div className="flex items-center gap-2">
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
{t("builtin.searchChain.apiKeySet")}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setShowKeyInput(true)}
>
{t("builtin.searchChain.apiKeyChange")}
</Button>
</div>
) : (
<Input
type="password"
autoComplete="off"
placeholder={
keyIsSet
? t("builtin.searchChain.apiKeyReplacePlaceholder")
: t("builtin.searchChain.apiKeyPlaceholder")
}
value={entry.apiKey ?? ""}
onChange={(e) => onUpdate(entry.id, { apiKey: e.target.value })}
className="h-7 flex-1 text-base md:text-sm font-mono"
/>
)}
</div>
</div>
</div>
);
@@ -157,7 +202,7 @@ function LockedDuckDuckGoCard({ settings }: { settings: Record<string, unknown>
);
}
export function WebSearchChainForm({ initialSettings, onSave, onCancel }: Props) {
export function WebSearchChainForm({ initialSettings, secretsSet, onSave, onCancel }: Props) {
const { t } = useTranslation("tools");
const [entries, setEntries] = useState<ProviderEntry[]>(() =>
parseInitialEntries(initialSettings),
@@ -192,6 +237,10 @@ export function WebSearchChainForm({ initialSettings, onSave, onCancel }: Props)
for (const entry of entries) {
const cfg: Record<string, unknown> = { 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}
/>
))}
</SortableContext>
</DndContext>
<LockedDuckDuckGoCard settings={initialSettings} />
<p className="text-xs text-muted-foreground mt-3 px-1">
{t("builtin.searchChain.apiKeyHint")}
</p>
</div>
<DialogFooter>