mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
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:
1 parent
81cd6ef24b
commit
31b2662ecf
8 files changed
+213
-21
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user