From f3a511bd604aafe624d87b3c68f6c6b5779ca3f5 Mon Sep 17 00:00:00 2001 From: Alexey Kazakov Date: Tue, 12 May 2026 17:43:55 -0700 Subject: [PATCH 1/3] feat: add web search and web fetch support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WebSearchSpec and WebFetchSpec to Claw CRD with CEL validation requiring secretRef for API-keyed providers (brave, tavily) - New claw_web_search.go: provider registry, validation, ConfigMap injection (operator.json), and proxy env var mounting - Proxy injects search API keys transparently; gateway sees only a placeholder — supports brave (api_key), tavily (bearer), duckduckgo (passthrough), and gemini (reuses google credential) - Wire validation, ConfigMap injection, proxy config routes, secret version stamping, and secret watch into the reconciler - Comprehensive tests covering validation, ConfigMap injection, proxy routes, deployment env vars, secret stamping, and full reconcile - Update PLATFORM.md skill with examples and provider-setup.md docs Signed-off-by: Alexey Kazakov Co-authored-by: Cursor --- api/v1alpha1/claw_types.go | 41 + api/v1alpha1/zz_generated.deepcopy.go | 50 + .../bases/claw.sandbox.redhat.com_claws.yaml | 62 ++ docs/provider-setup.md | 165 +++- internal/assets/manifests/claw/configmap.yaml | 79 +- internal/controller/claw_channels_test.go | 6 +- internal/controller/claw_proxy.go | 40 + internal/controller/claw_proxy_test.go | 68 +- .../controller/claw_resource_controller.go | 24 +- internal/controller/claw_web_search.go | 348 +++++++ internal/controller/claw_web_search_test.go | 916 ++++++++++++++++++ 11 files changed, 1758 insertions(+), 41 deletions(-) create mode 100644 internal/controller/claw_web_search.go create mode 100644 internal/controller/claw_web_search_test.go diff --git a/api/v1alpha1/claw_types.go b/api/v1alpha1/claw_types.go index d4dede8..02ca584 100644 --- a/api/v1alpha1/claw_types.go +++ b/api/v1alpha1/claw_types.go @@ -52,6 +52,7 @@ const ( ConditionTypeProxyConfigured = "ProxyConfigured" ConditionTypeDevicePairingConfigured = "DevicePairingConfigured" ConditionTypeMcpServersConfigured = "McpServersConfigured" + ConditionTypeWebSearchConfigured = "WebSearchConfigured" ) // Annotation keys used on pod templates to trigger rollouts on config changes. @@ -263,6 +264,36 @@ type McpEnvFromSecret struct { SecretRef SecretRefEntry `json:"secretRef"` } +// WebSearchSpec configures the operator-managed web search provider. +// +kubebuilder:validation:XValidation:rule="self.provider in ['duckduckgo','gemini'] || has(self.secretRef)",message="secretRef is required for API-keyed search providers" +type WebSearchSpec struct { + // Provider selects the web search provider. + // Known values: brave, tavily, duckduckgo, gemini. + // +kubebuilder:validation:MinLength=1 + Provider string `json:"provider"` + + // SecretRef references a Secret key holding the search API key. + // Required for API-keyed providers (brave, tavily). + // Not needed for key-free (duckduckgo) or LLM-as-search (gemini). + // +optional + SecretRef *SecretRefEntry `json:"secretRef,omitempty"` + + // Config is provider-specific configuration merged into + // plugins.entries..config.webSearch in operator.json. + // Use for provider-specific tuning (mode, maxResults, etc.). + // +kubebuilder:pruning:PreserveUnknownFields + // +optional + Config *runtime.RawExtension `json:"config,omitempty"` +} + +// WebFetchSpec configures the web_fetch tool. +type WebFetchSpec struct { + // Enabled activates the web_fetch tool. Fetched URLs are gated by + // the proxy allowlist. + // +kubebuilder:default=true + Enabled bool `json:"enabled"` +} + // ClawSpec defines the desired state of Claw type ClawSpec struct { // ConfigMode controls how operator config is applied on pod start. @@ -281,6 +312,16 @@ type ClawSpec struct { // Map keys are server names as they appear in the mcp.servers config. // +optional McpServers map[string]McpServerSpec `json:"mcpServers,omitempty"` + + // WebSearch configures the web search provider for the OpenClaw agent. + // +optional + WebSearch *WebSearchSpec `json:"webSearch,omitempty"` + + // WebFetch enables the web_fetch tool for arbitrary URL fetching. + // Fetched URLs are gated by the proxy allowlist — only domains + // permitted by credentials, search providers, or builtins are reachable. + // +optional + WebFetch *WebFetchSpec `json:"webFetch,omitempty"` } // ClawStatus defines the observed state of Claw diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 9227b56..7acd404 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -213,6 +213,16 @@ func (in *ClawSpec) DeepCopyInto(out *ClawSpec) { (*out)[key] = *val.DeepCopy() } } + if in.WebSearch != nil { + in, out := &in.WebSearch, &out.WebSearch + *out = new(WebSearchSpec) + (*in).DeepCopyInto(*out) + } + if in.WebFetch != nil { + in, out := &in.WebFetch, &out.WebFetch + *out = new(WebFetchSpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClawSpec. @@ -416,3 +426,43 @@ func (in *SecretRefEntry) DeepCopy() *SecretRefEntry { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebFetchSpec) DeepCopyInto(out *WebFetchSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebFetchSpec. +func (in *WebFetchSpec) DeepCopy() *WebFetchSpec { + if in == nil { + return nil + } + out := new(WebFetchSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebSearchSpec) DeepCopyInto(out *WebSearchSpec) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(SecretRefEntry) + **out = **in + } + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebSearchSpec. +func (in *WebSearchSpec) DeepCopy() *WebSearchSpec { + if in == nil { + return nil + } + out := new(WebSearchSpec) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/claw.sandbox.redhat.com_claws.yaml b/config/crd/bases/claw.sandbox.redhat.com_claws.yaml index 24dd13d..aee5629 100644 --- a/config/crd/bases/claw.sandbox.redhat.com_claws.yaml +++ b/config/crd/bases/claw.sandbox.redhat.com_claws.yaml @@ -327,6 +327,68 @@ spec: McpServers declares MCP servers injected into OpenClaw's config. Map keys are server names as they appear in the mcp.servers config. type: object + webFetch: + description: |- + WebFetch enables the web_fetch tool for arbitrary URL fetching. + Fetched URLs are gated by the proxy allowlist — only domains + permitted by credentials, search providers, or builtins are reachable. + properties: + enabled: + default: true + description: |- + Enabled activates the web_fetch tool. Fetched URLs are gated by + the proxy allowlist. + type: boolean + required: + - enabled + type: object + webSearch: + description: WebSearch configures the web search provider for the + OpenClaw agent. + properties: + config: + description: |- + Config is provider-specific configuration merged into + plugins.entries..config.webSearch in operator.json. + Use for provider-specific tuning (mode, maxResults, etc.). + type: object + x-kubernetes-preserve-unknown-fields: true + provider: + description: |- + Provider selects the web search provider. + Known values: brave, tavily, duckduckgo, gemini. + minLength: 1 + type: string + secretRef: + description: |- + SecretRef references a Secret key holding the search API key. + Required for API-keyed providers (brave, tavily). + Not needed for key-free (duckduckgo) or LLM-as-search (gemini). + properties: + key: + description: Key is the key in the Secret's data map + minLength: 1 + type: string + name: + description: Name is the name of the Secret + minLength: 1 + type: string + role: + description: |- + Role distinguishes multiple secrets for the same credential. + Required when multiple secretRef entries are present (e.g., Slack botToken/appToken). + maxLength: 63 + type: string + required: + - key + - name + type: object + required: + - provider + type: object + x-kubernetes-validations: + - message: secretRef is required for API-keyed search providers + rule: self.provider in ['duckduckgo','gemini'] || has(self.secretRef) type: object status: description: ClawStatus defines the observed state of Claw diff --git a/docs/provider-setup.md b/docs/provider-setup.md index 1e437a0..c6dee6a 100644 --- a/docs/provider-setup.md +++ b/docs/provider-setup.md @@ -1,6 +1,6 @@ # Provider Setup -This guide covers configuring LLM providers, external services, messaging channels, and MCP servers for use with Claw. Each section walks through creating the necessary Secret and Claw CR configuration. +This guide covers configuring LLM providers, external services, messaging channels, MCP servers, web search, and web fetch for use with Claw. Each section walks through creating the necessary Secret and Claw CR configuration. All examples assume you have set your target namespace: @@ -856,3 +856,166 @@ The operator reconciles `spec.mcpServers` into the `mcp.servers` section of `ope - **Overwrite mode**: The full config is replaced on every pod start, including MCP servers. The operator also validates that all `envFrom`-referenced Secrets exist and contain the specified keys. If validation fails, the `McpServersConfigured` condition is set to `False` with a descriptive error message, and `Ready` is set to `False`. + +## Web Search + +The operator can configure a web search provider for the OpenClaw agent via `spec.webSearch`. Search API keys are injected by the MITM proxy — they never reach the gateway container. + +### Brave Search + +Uses the [Brave Search API](https://brave.com/search/api/) with an API key injected via the `X-Subscription-Token` header. + +**1. Get an API key** from [Brave Search API](https://brave.com/search/api/). + +**2. Create the Secret:** + +```sh +oc create secret generic brave-search-key \ + --from-literal=api-key=YOUR_BRAVE_API_KEY \ + -n $NS +``` + +**3. Add to your Claw CR:** + +```sh +oc apply -f - <.config.webSearch. +func injectSearchPluginEntry(config map[string]any, ws *clawv1alpha1.WebSearchSpec) error { + pluginID := ws.Provider + + // Gemini uses the "google" plugin entry + if requiredProvider, ok := llmAsSearchProviders[ws.Provider]; ok { + pluginID = requiredProvider + } + + webSearchConfig := map[string]any{} + + // API-keyed providers get a placeholder key + if webSearchNeedsSecret(ws.Provider) { + webSearchConfig["apiKey"] = placeholderAPIKey + } + + // Merge user-provided config + if ws.Config != nil && ws.Config.Raw != nil { + var userConfig map[string]any + if err := json.Unmarshal(ws.Config.Raw, &userConfig); err != nil { + return fmt.Errorf("failed to parse webSearch.config: %w", err) + } + for k, v := range userConfig { + webSearchConfig[k] = v + } + } + + // Key-free providers with no user config need no plugin entry + if len(webSearchConfig) == 0 { + return nil + } + + existingPlugins, _ := config["plugins"].(map[string]any) + if existingPlugins == nil { + existingPlugins = map[string]any{} + } + existingEntries, _ := existingPlugins["entries"].(map[string]any) + if existingEntries == nil { + existingEntries = map[string]any{} + } + + pluginEntry, _ := existingEntries[pluginID].(map[string]any) + if pluginEntry == nil { + pluginEntry = map[string]any{} + } + pluginConfig, _ := pluginEntry["config"].(map[string]any) + if pluginConfig == nil { + pluginConfig = map[string]any{} + } + + pluginConfig["webSearch"] = webSearchConfig + pluginEntry["config"] = pluginConfig + existingEntries[pluginID] = pluginEntry + existingPlugins["entries"] = existingEntries + config["plugins"] = existingPlugins + + return nil +} + +// configureProxyForWebSearch mounts the search API key Secret as an env var +// on the proxy container. Only applies to API-keyed providers. +func configureProxyForWebSearch( + objects []*unstructured.Unstructured, + instance *clawv1alpha1.Claw, +) error { + if instance.Spec.WebSearch == nil { + return nil + } + if !webSearchNeedsSecret(instance.Spec.WebSearch.Provider) { + return nil + } + ref := instance.Spec.WebSearch.SecretRef + if ref == nil { + return nil + } + + for _, obj := range objects { + if obj.GetKind() != DeploymentKind || obj.GetName() != getProxyDeploymentName(instance.GetName()) { + continue + } + + containers, found, err := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "containers") + if err != nil { + return fmt.Errorf("failed to get containers from proxy deployment: %w", err) + } + if !found { + return fmt.Errorf("containers field not found in proxy deployment") + } + + for i, c := range containers { + cm, ok := c.(map[string]any) + if !ok { + continue + } + if name, _, _ := unstructured.NestedString(cm, "name"); name != ClawProxyContainerName { + continue + } + + envVars, _, _ := unstructured.NestedSlice(cm, "env") + envVars = append(envVars, map[string]any{ + "name": credEnvVarName(webSearchCredPrefix), + "valueFrom": map[string]any{ + "secretKeyRef": map[string]any{ + "name": ref.Name, + "key": ref.Key, + }, + }, + }) + + if err := unstructured.SetNestedSlice(cm, envVars, "env"); err != nil { + return fmt.Errorf("failed to set env vars: %w", err) + } + containers[i] = cm + if err := unstructured.SetNestedSlice( + obj.Object, containers, "spec", "template", "spec", "containers", + ); err != nil { + return fmt.Errorf("failed to set containers: %w", err) + } + return nil + } + return fmt.Errorf("container %q not found in proxy deployment", ClawProxyContainerName) + } + return fmt.Errorf("claw-proxy deployment not found in manifests") +} diff --git a/internal/controller/claw_web_search_test.go b/internal/controller/claw_web_search_test.go new file mode 100644 index 0000000..2617e50 --- /dev/null +++ b/internal/controller/claw_web_search_test.go @@ -0,0 +1,916 @@ +/* +Copyright 2026 Red Hat. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + clawv1alpha1 "github.com/codeready-toolchain/claw-operator/api/v1alpha1" +) + +func TestValidateWebSearchConfig(t *testing.T) { + ctx := context.Background() + + t.Run("brave with valid secret succeeds", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + secret := createTestAPIKeySecret("brave-key", namespace, "api-key", "test-brave-key") + require.NoError(t, k8sClient.Create(ctx, secret)) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "brave-key", Key: "api-key"}, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + err := reconciler.validateWebSearchConfig(ctx, instance) + require.NoError(t, err) + cond := apimeta.FindStatusCondition(instance.Status.Conditions, clawv1alpha1.ConditionTypeWebSearchConfigured) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + }) + + t.Run("brave with missing secret fails", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "nonexistent", Key: "api-key"}, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + err := reconciler.validateWebSearchConfig(ctx, instance) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + cond := apimeta.FindStatusCondition(instance.Status.Conditions, clawv1alpha1.ConditionTypeWebSearchConfigured) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + }) + + t.Run("brave with wrong key fails", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + secret := createTestAPIKeySecret("brave-key2", namespace, "api-key", "val") + require.NoError(t, k8sClient.Create(ctx, secret)) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "brave-key2", Key: "wrong-key"}, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + err := reconciler.validateWebSearchConfig(ctx, instance) + require.Error(t, err) + assert.Contains(t, err.Error(), `key "wrong-key" not found`) + cond := apimeta.FindStatusCondition(instance.Status.Conditions, clawv1alpha1.ConditionTypeWebSearchConfigured) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + }) + + t.Run("tavily with valid secret succeeds", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + secret := createTestAPIKeySecret("tavily-key", namespace, "api-key", "test-tavily-key") + require.NoError(t, k8sClient.Create(ctx, secret)) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "tavily", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "tavily-key", Key: "api-key"}, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + require.NoError(t, reconciler.validateWebSearchConfig(ctx, instance)) + cond := apimeta.FindStatusCondition(instance.Status.Conditions, clawv1alpha1.ConditionTypeWebSearchConfigured) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + }) + + t.Run("duckduckgo needs no secret", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "duckduckgo", + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + require.NoError(t, reconciler.validateWebSearchConfig(ctx, instance)) + cond := apimeta.FindStatusCondition(instance.Status.Conditions, clawv1alpha1.ConditionTypeWebSearchConfigured) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + }) + + t.Run("gemini with google credential succeeds", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + secret := createTestAPIKeySecret("google-api-key", namespace, "api-key", "test-google-key") + require.NoError(t, k8sClient.Create(ctx, secret)) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + Credentials: []clawv1alpha1.CredentialSpec{ + {Name: "google", Provider: "google", Type: clawv1alpha1.CredentialTypeAPIKey, + Domain: ".googleapis.com", + SecretRef: []clawv1alpha1.SecretRefEntry{{Name: "google-api-key", Key: "api-key"}}}, + }, + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "gemini", + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + require.NoError(t, reconciler.validateWebSearchConfig(ctx, instance)) + cond := apimeta.FindStatusCondition(instance.Status.Conditions, clawv1alpha1.ConditionTypeWebSearchConfigured) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + }) + + t.Run("gemini without google credential fails", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "gemini", + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + err := reconciler.validateWebSearchConfig(ctx, instance) + require.Error(t, err) + assert.Contains(t, err.Error(), `requires a "google" credential`) + cond := apimeta.FindStatusCondition(instance.Status.Conditions, clawv1alpha1.ConditionTypeWebSearchConfigured) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + }) + + t.Run("unknown provider fails", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + secret := createTestAPIKeySecret("bogus-key", namespace, "api-key", "val") + require.NoError(t, k8sClient.Create(ctx, secret)) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "bogus", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "bogus-key", Key: "api-key"}, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + err := reconciler.validateWebSearchConfig(ctx, instance) + require.Error(t, err) + assert.Equal(t, `unknown web search provider "bogus"`, err.Error()) + }) +} + +func TestInjectWebSearchIntoConfigMap(t *testing.T) { + t.Run("brave sets tools.web.search and plugins entry with placeholder", func(t *testing.T) { + reconciler := createClawReconciler() + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "s", Key: "k"}, + }, + }, + } + objects, err := reconciler.buildKustomizedObjects(instance) + require.NoError(t, err) + + require.NoError(t, injectWebSearchIntoConfigMap(objects, instance)) + + config := extractOperatorJSON(t, objects, instance.Name) + + tools, ok := config["tools"].(map[string]any) + require.True(t, ok) + web, ok := tools["web"].(map[string]any) + require.True(t, ok) + search, ok := web["search"].(map[string]any) + require.True(t, ok) + assert.Equal(t, true, search["enabled"]) + assert.Equal(t, "brave", search["provider"]) + + plugins, ok := config["plugins"].(map[string]any) + require.True(t, ok) + entries, ok := plugins["entries"].(map[string]any) + require.True(t, ok) + braveEntry, ok := entries["brave"].(map[string]any) + require.True(t, ok) + braveConfig, ok := braveEntry["config"].(map[string]any) + require.True(t, ok) + webSearch, ok := braveConfig["webSearch"].(map[string]any) + require.True(t, ok) + assert.Equal(t, placeholderAPIKey, webSearch["apiKey"]) + }) + + t.Run("tavily with custom config merges into plugin entry", func(t *testing.T) { + reconciler := createClawReconciler() + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "tavily", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "s", Key: "k"}, + Config: &runtime.RawExtension{Raw: []byte(`{"maxResults":10}`)}, + }, + }, + } + objects, err := reconciler.buildKustomizedObjects(instance) + require.NoError(t, err) + + require.NoError(t, injectWebSearchIntoConfigMap(objects, instance)) + + config := extractOperatorJSON(t, objects, instance.Name) + plugins := config["plugins"].(map[string]any) + entries := plugins["entries"].(map[string]any) + tavilyEntry := entries["tavily"].(map[string]any) + tavilyConfig := tavilyEntry["config"].(map[string]any) + webSearch := tavilyConfig["webSearch"].(map[string]any) + assert.Equal(t, placeholderAPIKey, webSearch["apiKey"]) + assert.Equal(t, float64(10), webSearch["maxResults"]) + }) + + t.Run("duckduckgo sets search provider with no plugin entry", func(t *testing.T) { + reconciler := createClawReconciler() + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "duckduckgo", + }, + }, + } + objects, err := reconciler.buildKustomizedObjects(instance) + require.NoError(t, err) + + require.NoError(t, injectWebSearchIntoConfigMap(objects, instance)) + + config := extractOperatorJSON(t, objects, instance.Name) + tools := config["tools"].(map[string]any) + web := tools["web"].(map[string]any) + search := web["search"].(map[string]any) + assert.Equal(t, "duckduckgo", search["provider"]) + + // No plugin entry for duckduckgo (key-free) + _, hasPlugins := config["plugins"] + assert.False(t, hasPlugins) + }) + + t.Run("gemini sets search provider under google plugin entry", func(t *testing.T) { + reconciler := createClawReconciler() + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "gemini", + Config: &runtime.RawExtension{Raw: []byte(`{"model":"gemini-2.0-flash"}`)}, + }, + }, + } + objects, err := reconciler.buildKustomizedObjects(instance) + require.NoError(t, err) + + require.NoError(t, injectWebSearchIntoConfigMap(objects, instance)) + + config := extractOperatorJSON(t, objects, instance.Name) + tools := config["tools"].(map[string]any) + web := tools["web"].(map[string]any) + search := web["search"].(map[string]any) + assert.Equal(t, "gemini", search["provider"]) + + plugins := config["plugins"].(map[string]any) + entries := plugins["entries"].(map[string]any) + googleEntry := entries["google"].(map[string]any) + googleConfig := googleEntry["config"].(map[string]any) + webSearch := googleConfig["webSearch"].(map[string]any) + assert.Equal(t, "gemini-2.0-flash", webSearch["model"]) + _, hasAPIKey := webSearch["apiKey"] + assert.False(t, hasAPIKey, "gemini should not have placeholder apiKey") + }) + + t.Run("webFetch sets tools.web.fetch.enabled", func(t *testing.T) { + reconciler := createClawReconciler() + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebFetch: &clawv1alpha1.WebFetchSpec{Enabled: true}, + }, + } + objects, err := reconciler.buildKustomizedObjects(instance) + require.NoError(t, err) + + require.NoError(t, injectWebSearchIntoConfigMap(objects, instance)) + + config := extractOperatorJSON(t, objects, instance.Name) + tools := config["tools"].(map[string]any) + web := tools["web"].(map[string]any) + fetch := web["fetch"].(map[string]any) + assert.Equal(t, true, fetch["enabled"]) + }) + + t.Run("no-op when neither webSearch nor webFetch set", func(t *testing.T) { + reconciler := createClawReconciler() + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + } + objects, err := reconciler.buildKustomizedObjects(instance) + require.NoError(t, err) + + require.NoError(t, injectWebSearchIntoConfigMap(objects, instance)) + + config := extractOperatorJSON(t, objects, instance.Name) + _, hasTools := config["tools"] + assert.False(t, hasTools, "tools should not be added when web search/fetch are nil") + }) +} + +func TestGenerateProxyConfigWithWebSearch(t *testing.T) { + t.Run("brave adds api_key route with custom header", func(t *testing.T) { + ws := &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "s", Key: "k"}, + } + data, err := generateProxyConfig(nil, nil, ws) + require.NoError(t, err) + + var cfg proxyConfig + require.NoError(t, json.Unmarshal(data, &cfg)) + + route := findRouteByDomain(t, cfg.Routes, "api.search.brave.com") + assert.Equal(t, "api_key", route.Injector) + assert.Equal(t, "X-Subscription-Token", route.Header) + assert.Equal(t, "CRED_WEBSEARCH", route.EnvVar) + }) + + t.Run("tavily adds bearer route", func(t *testing.T) { + ws := &clawv1alpha1.WebSearchSpec{ + Provider: "tavily", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "s", Key: "k"}, + } + data, err := generateProxyConfig(nil, nil, ws) + require.NoError(t, err) + + var cfg proxyConfig + require.NoError(t, json.Unmarshal(data, &cfg)) + + route := findRouteByDomain(t, cfg.Routes, "api.tavily.com") + assert.Equal(t, "bearer", route.Injector) + assert.Equal(t, "CRED_WEBSEARCH", route.EnvVar) + }) + + t.Run("duckduckgo adds passthrough route", func(t *testing.T) { + ws := &clawv1alpha1.WebSearchSpec{Provider: "duckduckgo"} + data, err := generateProxyConfig(nil, nil, ws) + require.NoError(t, err) + + var cfg proxyConfig + require.NoError(t, json.Unmarshal(data, &cfg)) + + route := findRouteByDomain(t, cfg.Routes, "html.duckduckgo.com") + assert.Equal(t, "none", route.Injector) + assert.Empty(t, route.EnvVar) + }) + + t.Run("gemini adds no new route", func(t *testing.T) { + ws := &clawv1alpha1.WebSearchSpec{Provider: "gemini"} + data, err := generateProxyConfig(nil, nil, ws) + require.NoError(t, err) + + var cfg proxyConfig + require.NoError(t, json.Unmarshal(data, &cfg)) + + for _, route := range cfg.Routes { + assert.NotContains(t, route.Domain, "gemini", + "gemini should not add its own route") + } + }) + + t.Run("nil webSearch adds no search route", func(t *testing.T) { + data, err := generateProxyConfig(nil, nil, nil) + require.NoError(t, err) + + var cfg proxyConfig + require.NoError(t, json.Unmarshal(data, &cfg)) + + for _, route := range cfg.Routes { + assert.NotEqual(t, "api.search.brave.com", route.Domain) + assert.NotEqual(t, "api.tavily.com", route.Domain) + assert.NotEqual(t, "html.duckduckgo.com", route.Domain) + } + }) +} + +func TestConfigureProxyForWebSearch(t *testing.T) { + buildObjects := func(t *testing.T) (*clawv1alpha1.Claw, []*unstructured.Unstructured) { + t.Helper() + reconciler := createClawReconciler() + instance := &clawv1alpha1.Claw{} + instance.Name = testInstanceName + instance.Namespace = namespace + objects, err := reconciler.buildKustomizedObjects(instance) + require.NoError(t, err) + return instance, objects + } + + findProxyEnvVars := func(t *testing.T, objects []*unstructured.Unstructured) []any { + t.Helper() + for _, obj := range objects { + if obj.GetKind() != DeploymentKind || obj.GetName() != getProxyDeploymentName(testInstanceName) { + continue + } + containers, _, _ := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "containers") + for _, c := range containers { + cm, ok := c.(map[string]any) + if !ok { + continue + } + if name, _, _ := unstructured.NestedString(cm, "name"); name == ClawProxyContainerName { + envVars, _, _ := unstructured.NestedSlice(cm, "env") + return envVars + } + } + } + t.Fatal("proxy container not found") + return nil + } + + hasEnvVar := func(envVars []any, name string) bool { + for _, e := range envVars { + em, ok := e.(map[string]any) + if !ok { + continue + } + if n, _, _ := unstructured.NestedString(em, "name"); n == name { + return true + } + } + return false + } + + t.Run("adds CRED_WEBSEARCH for brave", func(t *testing.T) { + instance, objects := buildObjects(t) + instance.Spec.WebSearch = &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "brave-key", Key: "api-key"}, + } + + require.NoError(t, configureProxyForWebSearch(objects, instance)) + + envVars := findProxyEnvVars(t, objects) + assert.True(t, hasEnvVar(envVars, "CRED_WEBSEARCH"), "CRED_WEBSEARCH should be added") + }) + + t.Run("adds CRED_WEBSEARCH for tavily", func(t *testing.T) { + instance, objects := buildObjects(t) + instance.Spec.WebSearch = &clawv1alpha1.WebSearchSpec{ + Provider: "tavily", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "tavily-key", Key: "api-key"}, + } + + require.NoError(t, configureProxyForWebSearch(objects, instance)) + + envVars := findProxyEnvVars(t, objects) + assert.True(t, hasEnvVar(envVars, "CRED_WEBSEARCH"), "CRED_WEBSEARCH should be added") + }) + + t.Run("no env var for duckduckgo", func(t *testing.T) { + instance, objects := buildObjects(t) + instance.Spec.WebSearch = &clawv1alpha1.WebSearchSpec{ + Provider: "duckduckgo", + } + + require.NoError(t, configureProxyForWebSearch(objects, instance)) + + envVars := findProxyEnvVars(t, objects) + assert.False(t, hasEnvVar(envVars, "CRED_WEBSEARCH"), "duckduckgo should not add CRED_WEBSEARCH") + }) + + t.Run("no env var for gemini", func(t *testing.T) { + instance, objects := buildObjects(t) + instance.Spec.WebSearch = &clawv1alpha1.WebSearchSpec{ + Provider: "gemini", + } + + require.NoError(t, configureProxyForWebSearch(objects, instance)) + + envVars := findProxyEnvVars(t, objects) + assert.False(t, hasEnvVar(envVars, "CRED_WEBSEARCH"), "gemini should not add CRED_WEBSEARCH") + }) + + t.Run("no-op when webSearch is nil", func(t *testing.T) { + instance, objects := buildObjects(t) + require.NoError(t, configureProxyForWebSearch(objects, instance)) + + envVars := findProxyEnvVars(t, objects) + assert.False(t, hasEnvVar(envVars, "CRED_WEBSEARCH")) + }) +} + +func TestStampSecretVersionWithWebSearch(t *testing.T) { + ctx := context.Background() + + t.Run("stamps web search secret ResourceVersion", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + secret := createTestAPIKeySecret("ws-secret", namespace, "api-key", "brave-key-val") + require.NoError(t, k8sClient.Create(ctx, secret)) + + // Re-read to get ResourceVersion + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{Name: "ws-secret", Namespace: namespace}, secret)) + + createClawInstance(t, ctx, testInstanceName, namespace) + + // Update the Claw instance with webSearch + instance := &clawv1alpha1.Claw{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{Name: testInstanceName, Namespace: namespace}, instance)) + instance.Spec.WebSearch = &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "ws-secret", Key: "api-key"}, + } + require.NoError(t, k8sClient.Update(ctx, instance)) + + reconciler := createClawReconciler() + reconcileClaw(t, ctx, reconciler, testInstanceName, namespace) + + deployment := &appsv1.Deployment{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{ + Name: getProxyDeploymentName(testInstanceName), + Namespace: namespace, + }, deployment)) + + annotations := deployment.Spec.Template.Annotations + require.NotNil(t, annotations) + wsKey := clawv1alpha1.AnnotationPrefixSecretVersion + webSearchCredPrefix + clawv1alpha1.AnnotationSuffixSecretVersion + rv, ok := annotations[wsKey] + assert.True(t, ok, "websearch-secret-version annotation should exist") + assert.Equal(t, secret.ResourceVersion, rv) + }) +} + +func TestWebSearchFullReconcile(t *testing.T) { + ctx := context.Background() + + t.Run("brave web search produces correct proxy config and operator.json", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + braveSecret := createTestAPIKeySecret("brave-search-secret", namespace, "api-key", "brave-val") + require.NoError(t, k8sClient.Create(ctx, braveSecret)) + + createClawInstance(t, ctx, testInstanceName, namespace) + + instance := &clawv1alpha1.Claw{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{Name: testInstanceName, Namespace: namespace}, instance)) + instance.Spec.WebSearch = &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "brave-search-secret", Key: "api-key"}, + } + instance.Spec.WebFetch = &clawv1alpha1.WebFetchSpec{Enabled: true} + require.NoError(t, k8sClient.Update(ctx, instance)) + + reconciler := createClawReconciler() + reconcileClaw(t, ctx, reconciler, testInstanceName, namespace) + + // Verify proxy config JSON contains the brave route + proxyConfigCM := &corev1.ConfigMap{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{ + Name: getProxyConfigMapName(testInstanceName), + Namespace: namespace, + }, proxyConfigCM)) + var proxyCfg proxyConfig + require.NoError(t, json.Unmarshal([]byte(proxyConfigCM.Data["proxy-config.json"]), &proxyCfg)) + braveRoute := findRouteByDomain(t, proxyCfg.Routes, "api.search.brave.com") + assert.Equal(t, "api_key", braveRoute.Injector) + assert.Equal(t, "X-Subscription-Token", braveRoute.Header) + assert.Equal(t, "CRED_WEBSEARCH", braveRoute.EnvVar) + + // Verify proxy deployment has CRED_WEBSEARCH env var + proxyDeploy := &appsv1.Deployment{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{ + Name: getProxyDeploymentName(testInstanceName), + Namespace: namespace, + }, proxyDeploy)) + var foundEnv bool + for _, c := range proxyDeploy.Spec.Template.Spec.Containers { + if c.Name == ClawProxyContainerName { + for _, e := range c.Env { + if e.Name == "CRED_WEBSEARCH" { + foundEnv = true + require.NotNil(t, e.ValueFrom) + require.NotNil(t, e.ValueFrom.SecretKeyRef) + assert.Equal(t, "brave-search-secret", e.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "api-key", e.ValueFrom.SecretKeyRef.Key) + } + } + } + } + assert.True(t, foundEnv, "CRED_WEBSEARCH env var should exist on proxy container") + + // Verify operator.json has web search and web fetch config + gwConfigCM := &corev1.ConfigMap{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{ + Name: getConfigMapName(testInstanceName), + Namespace: namespace, + }, gwConfigCM)) + var opJSON map[string]any + require.NoError(t, json.Unmarshal([]byte(gwConfigCM.Data["operator.json"]), &opJSON)) + tools := opJSON["tools"].(map[string]any) + web := tools["web"].(map[string]any) + search := web["search"].(map[string]any) + assert.Equal(t, true, search["enabled"]) + assert.Equal(t, "brave", search["provider"]) + fetch := web["fetch"].(map[string]any) + assert.Equal(t, true, fetch["enabled"]) + + plugins := opJSON["plugins"].(map[string]any) + entries := plugins["entries"].(map[string]any) + braveEntry := entries["brave"].(map[string]any) + braveConfig := braveEntry["config"].(map[string]any) + webSearch := braveConfig["webSearch"].(map[string]any) + assert.Equal(t, placeholderAPIKey, webSearch["apiKey"]) + + // Verify WebSearchConfigured condition + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{Name: testInstanceName, Namespace: namespace}, instance)) + cond := apimeta.FindStatusCondition(instance.Status.Conditions, clawv1alpha1.ConditionTypeWebSearchConfigured) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + }) + + t.Run("duckduckgo reconcile requires no secret and sets condition", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + createClawInstance(t, ctx, testInstanceName, namespace) + + instance := &clawv1alpha1.Claw{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{Name: testInstanceName, Namespace: namespace}, instance)) + instance.Spec.WebSearch = &clawv1alpha1.WebSearchSpec{Provider: "duckduckgo"} + require.NoError(t, k8sClient.Update(ctx, instance)) + + reconciler := createClawReconciler() + reconcileClaw(t, ctx, reconciler, testInstanceName, namespace) + + // Verify proxy config has duckduckgo passthrough + proxyConfigCM := &corev1.ConfigMap{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{ + Name: getProxyConfigMapName(testInstanceName), + Namespace: namespace, + }, proxyConfigCM)) + var proxyCfg proxyConfig + require.NoError(t, json.Unmarshal([]byte(proxyConfigCM.Data["proxy-config.json"]), &proxyCfg)) + ddgRoute := findRouteByDomain(t, proxyCfg.Routes, "html.duckduckgo.com") + assert.Equal(t, "none", ddgRoute.Injector) + assert.Empty(t, ddgRoute.EnvVar) + + // No CRED_WEBSEARCH on proxy deployment + proxyDeploy := &appsv1.Deployment{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{ + Name: getProxyDeploymentName(testInstanceName), + Namespace: namespace, + }, proxyDeploy)) + for _, c := range proxyDeploy.Spec.Template.Spec.Containers { + if c.Name == ClawProxyContainerName { + for _, e := range c.Env { + assert.NotEqual(t, "CRED_WEBSEARCH", e.Name, "duckduckgo should not add CRED_WEBSEARCH") + } + } + } + + // Condition should be True + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{Name: testInstanceName, Namespace: namespace}, instance)) + cond := apimeta.FindStatusCondition(instance.Status.Conditions, clawv1alpha1.ConditionTypeWebSearchConfigured) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + }) + + t.Run("condition removed when webSearch is nil", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + createClawInstance(t, ctx, testInstanceName, namespace) + + reconciler := createClawReconciler() + reconcileClaw(t, ctx, reconciler, testInstanceName, namespace) + + instance := &clawv1alpha1.Claw{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{Name: testInstanceName, Namespace: namespace}, instance)) + cond := apimeta.FindStatusCondition(instance.Status.Conditions, clawv1alpha1.ConditionTypeWebSearchConfigured) + assert.Nil(t, cond, "WebSearchConfigured should not be present when webSearch is nil") + }) +} + +func TestInjectWebSearchAndFetchCombined(t *testing.T) { + t.Run("both webSearch and webFetch set together", func(t *testing.T) { + reconciler := createClawReconciler() + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "tavily", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "s", Key: "k"}, + }, + WebFetch: &clawv1alpha1.WebFetchSpec{Enabled: true}, + }, + } + objects, err := reconciler.buildKustomizedObjects(instance) + require.NoError(t, err) + + require.NoError(t, injectWebSearchIntoConfigMap(objects, instance)) + + config := extractOperatorJSON(t, objects, instance.Name) + tools := config["tools"].(map[string]any) + web := tools["web"].(map[string]any) + + search := web["search"].(map[string]any) + assert.Equal(t, true, search["enabled"]) + assert.Equal(t, "tavily", search["provider"]) + + fetch := web["fetch"].(map[string]any) + assert.Equal(t, true, fetch["enabled"]) + + plugins := config["plugins"].(map[string]any) + entries := plugins["entries"].(map[string]any) + tavilyEntry := entries["tavily"].(map[string]any) + tavilyConfig := tavilyEntry["config"].(map[string]any) + webSearch := tavilyConfig["webSearch"].(map[string]any) + assert.Equal(t, placeholderAPIKey, webSearch["apiKey"]) + }) +} + +func TestConfigureProxyForWebSearchSecretRef(t *testing.T) { + t.Run("secretKeyRef references correct secret name and key", func(t *testing.T) { + reconciler := createClawReconciler() + instance := &clawv1alpha1.Claw{} + instance.Name = testInstanceName + instance.Namespace = namespace + instance.Spec.WebSearch = &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "my-brave-secret", Key: "my-key"}, + } + objects, err := reconciler.buildKustomizedObjects(instance) + require.NoError(t, err) + + require.NoError(t, configureProxyForWebSearch(objects, instance)) + + for _, obj := range objects { + if obj.GetKind() != DeploymentKind || obj.GetName() != getProxyDeploymentName(testInstanceName) { + continue + } + containers, _, _ := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "containers") + for _, c := range containers { + cm := c.(map[string]any) + if name, _, _ := unstructured.NestedString(cm, "name"); name != ClawProxyContainerName { + continue + } + envVars, _, _ := unstructured.NestedSlice(cm, "env") + for _, e := range envVars { + em := e.(map[string]any) + if n, _, _ := unstructured.NestedString(em, "name"); n == "CRED_WEBSEARCH" { + secretName, _, _ := unstructured.NestedString(em, "valueFrom", "secretKeyRef", "name") + secretKey, _, _ := unstructured.NestedString(em, "valueFrom", "secretKeyRef", "key") + assert.Equal(t, "my-brave-secret", secretName) + assert.Equal(t, "my-key", secretKey) + return + } + } + } + } + t.Fatal("CRED_WEBSEARCH env var not found") + }) +} + +func TestWebSearchRouteHelper(t *testing.T) { + t.Run("nil returns false", func(t *testing.T) { + _, ok := webSearchRoute(nil) + assert.False(t, ok) + }) + + t.Run("unknown provider returns false", func(t *testing.T) { + _, ok := webSearchRoute(&clawv1alpha1.WebSearchSpec{Provider: "unknown"}) + assert.False(t, ok) + }) + + t.Run("gemini returns false", func(t *testing.T) { + _, ok := webSearchRoute(&clawv1alpha1.WebSearchSpec{Provider: "gemini"}) + assert.False(t, ok) + }) + + t.Run("brave returns correct route", func(t *testing.T) { + route, ok := webSearchRoute(&clawv1alpha1.WebSearchSpec{Provider: "brave"}) + require.True(t, ok) + assert.Equal(t, "api.search.brave.com", route.Domain) + assert.Equal(t, "api_key", route.Injector) + assert.Equal(t, "X-Subscription-Token", route.Header) + assert.Equal(t, "CRED_WEBSEARCH", route.EnvVar) + }) + + t.Run("tavily returns correct route", func(t *testing.T) { + route, ok := webSearchRoute(&clawv1alpha1.WebSearchSpec{Provider: "tavily"}) + require.True(t, ok) + assert.Equal(t, "api.tavily.com", route.Domain) + assert.Equal(t, "bearer", route.Injector) + assert.Equal(t, "CRED_WEBSEARCH", route.EnvVar) + assert.Empty(t, route.Header) + }) + + t.Run("duckduckgo returns passthrough route", func(t *testing.T) { + route, ok := webSearchRoute(&clawv1alpha1.WebSearchSpec{Provider: "duckduckgo"}) + require.True(t, ok) + assert.Equal(t, "html.duckduckgo.com", route.Domain) + assert.Equal(t, "none", route.Injector) + assert.Empty(t, route.EnvVar) + }) +} + +func TestClawReferencesSecretWebSearch(t *testing.T) { + t.Run("matches web search secret", func(t *testing.T) { + instance := clawv1alpha1.Claw{ + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "brave-search-key", Key: "api-key"}, + }, + }, + } + assert.True(t, clawReferencesSecret(instance, "brave-search-key")) + assert.False(t, clawReferencesSecret(instance, "other-secret")) + }) + + t.Run("no match when webSearch is nil", func(t *testing.T) { + instance := clawv1alpha1.Claw{} + assert.False(t, clawReferencesSecret(instance, "anything")) + }) + + t.Run("no match when secretRef is nil", func(t *testing.T) { + instance := clawv1alpha1.Claw{ + Spec: clawv1alpha1.ClawSpec{ + WebSearch: &clawv1alpha1.WebSearchSpec{Provider: "duckduckgo"}, + }, + } + assert.False(t, clawReferencesSecret(instance, "anything")) + }) +} From 8ef150467f38c7b8b999c3c15068f811bb77550b Mon Sep 17 00:00:00 2001 From: Alexey Kazakov Date: Tue, 12 May 2026 17:52:06 -0700 Subject: [PATCH 2/3] fix: prevent duplicate routes when web search domain is already covered - Move web search route insertion before MCP passthrough extraction and gate it with domainCovered() to skip when a credential or suffix-match already covers the domain - Add tests for exact-match and suffix-match deduplication Signed-off-by: Alexey Kazakov Co-authored-by: Cursor --- internal/controller/claw_proxy.go | 12 +++-- internal/controller/claw_web_search_test.go | 51 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/internal/controller/claw_proxy.go b/internal/controller/claw_proxy.go index 1b05bc0..844aff3 100644 --- a/internal/controller/claw_proxy.go +++ b/internal/controller/claw_proxy.go @@ -123,6 +123,14 @@ func generateProxyConfig( } } + if wsRoute, ok := webSearchRoute(webSearch); ok { + wsDomain := strings.ToLower(wsRoute.Domain) + if !domainCovered(wsDomain, coveredDomains) { + coveredDomains[wsDomain] = true + exact = append(exact, wsRoute) + } + } + exact = append(exact, mcpPassthroughRoutes(mcpServers, coveredDomains)...) for _, rc := range credentials { @@ -217,10 +225,6 @@ func generateProxyConfig( } } - if route, ok := webSearchRoute(webSearch); ok { - exact = append(exact, route) - } - // Stable ordering: exact before suffix, alphabetical within each group. // Within the same domain, routes with AllowedPaths sort before catch-all routes // so the proxy's MatchRoute picks the specific route first. diff --git a/internal/controller/claw_web_search_test.go b/internal/controller/claw_web_search_test.go index 2617e50..59834f3 100644 --- a/internal/controller/claw_web_search_test.go +++ b/internal/controller/claw_web_search_test.go @@ -465,6 +465,57 @@ func TestGenerateProxyConfigWithWebSearch(t *testing.T) { assert.NotEqual(t, "html.duckduckgo.com", route.Domain) } }) + + t.Run("skips web search route when credential already covers domain", func(t *testing.T) { + creds := []resolvedCredential{{ + CredentialSpec: clawv1alpha1.CredentialSpec{ + Name: "brave-cred", + Domain: "api.search.brave.com", + Type: clawv1alpha1.CredentialTypeBearer, + }, + }} + ws := &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "s", Key: "k"}, + } + data, err := generateProxyConfig(creds, nil, ws) + require.NoError(t, err) + + var cfg proxyConfig + require.NoError(t, json.Unmarshal(data, &cfg)) + + var count int + for _, route := range cfg.Routes { + if route.Domain == "api.search.brave.com" { + count++ + } + } + assert.Equal(t, 1, count, "domain should appear exactly once (from credential, not web search)") + }) + + t.Run("skips web search route when suffix credential covers domain", func(t *testing.T) { + creds := []resolvedCredential{{ + CredentialSpec: clawv1alpha1.CredentialSpec{ + Name: "brave-wildcard", + Domain: ".brave.com", + Type: clawv1alpha1.CredentialTypeBearer, + }, + }} + ws := &clawv1alpha1.WebSearchSpec{ + Provider: "brave", + SecretRef: &clawv1alpha1.SecretRefEntry{Name: "s", Key: "k"}, + } + data, err := generateProxyConfig(creds, nil, ws) + require.NoError(t, err) + + var cfg proxyConfig + require.NoError(t, json.Unmarshal(data, &cfg)) + + for _, route := range cfg.Routes { + assert.NotEqual(t, "api.search.brave.com", route.Domain, + "web search route should be skipped when suffix credential covers the domain") + } + }) } func TestConfigureProxyForWebSearch(t *testing.T) { From 027fcee48ceabca003612b197e53c798b8f23642 Mon Sep 17 00:00:00 2001 From: Alexey Kazakov Date: Tue, 12 May 2026 17:57:44 -0700 Subject: [PATCH 3/3] test: harden web search ConfigMap injection assertions - Guard type assertions in tavily and gemini subtests with require to produce clear failures instead of panics - Replace brittle "no plugins block" assertion in duckduckgo test with a targeted check that duckduckgo has no plugin entry Signed-off-by: Alexey Kazakov Co-authored-by: Cursor --- internal/controller/claw_web_search_test.go | 57 ++++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/internal/controller/claw_web_search_test.go b/internal/controller/claw_web_search_test.go index 59834f3..3dfeb1d 100644 --- a/internal/controller/claw_web_search_test.go +++ b/internal/controller/claw_web_search_test.go @@ -288,11 +288,16 @@ func TestInjectWebSearchIntoConfigMap(t *testing.T) { require.NoError(t, injectWebSearchIntoConfigMap(objects, instance)) config := extractOperatorJSON(t, objects, instance.Name) - plugins := config["plugins"].(map[string]any) - entries := plugins["entries"].(map[string]any) - tavilyEntry := entries["tavily"].(map[string]any) - tavilyConfig := tavilyEntry["config"].(map[string]any) - webSearch := tavilyConfig["webSearch"].(map[string]any) + plugins, ok := config["plugins"].(map[string]any) + require.True(t, ok, "plugins should be present") + entries, ok := plugins["entries"].(map[string]any) + require.True(t, ok, "plugins.entries should be present") + tavilyEntry, ok := entries["tavily"].(map[string]any) + require.True(t, ok, "plugins.entries.tavily should be present") + tavilyConfig, ok := tavilyEntry["config"].(map[string]any) + require.True(t, ok, "plugins.entries.tavily.config should be present") + webSearch, ok := tavilyConfig["webSearch"].(map[string]any) + require.True(t, ok, "plugins.entries.tavily.config.webSearch should be present") assert.Equal(t, placeholderAPIKey, webSearch["apiKey"]) assert.Equal(t, float64(10), webSearch["maxResults"]) }) @@ -313,14 +318,20 @@ func TestInjectWebSearchIntoConfigMap(t *testing.T) { require.NoError(t, injectWebSearchIntoConfigMap(objects, instance)) config := extractOperatorJSON(t, objects, instance.Name) - tools := config["tools"].(map[string]any) - web := tools["web"].(map[string]any) - search := web["search"].(map[string]any) + tools, ok := config["tools"].(map[string]any) + require.True(t, ok, "tools should be present") + web, ok := tools["web"].(map[string]any) + require.True(t, ok, "tools.web should be present") + search, ok := web["search"].(map[string]any) + require.True(t, ok, "tools.web.search should be present") assert.Equal(t, "duckduckgo", search["provider"]) - // No plugin entry for duckduckgo (key-free) - _, hasPlugins := config["plugins"] - assert.False(t, hasPlugins) + plugins, _ := config["plugins"].(map[string]any) + if plugins != nil { + entries, _ := plugins["entries"].(map[string]any) + _, hasDDG := entries["duckduckgo"] + assert.False(t, hasDDG, "duckduckgo should not have a plugin entry") + } }) t.Run("gemini sets search provider under google plugin entry", func(t *testing.T) { @@ -340,16 +351,24 @@ func TestInjectWebSearchIntoConfigMap(t *testing.T) { require.NoError(t, injectWebSearchIntoConfigMap(objects, instance)) config := extractOperatorJSON(t, objects, instance.Name) - tools := config["tools"].(map[string]any) - web := tools["web"].(map[string]any) - search := web["search"].(map[string]any) + tools, ok := config["tools"].(map[string]any) + require.True(t, ok, "tools should be present") + web, ok := tools["web"].(map[string]any) + require.True(t, ok, "tools.web should be present") + search, ok := web["search"].(map[string]any) + require.True(t, ok, "tools.web.search should be present") assert.Equal(t, "gemini", search["provider"]) - plugins := config["plugins"].(map[string]any) - entries := plugins["entries"].(map[string]any) - googleEntry := entries["google"].(map[string]any) - googleConfig := googleEntry["config"].(map[string]any) - webSearch := googleConfig["webSearch"].(map[string]any) + plugins, ok := config["plugins"].(map[string]any) + require.True(t, ok, "plugins should be present") + entries, ok := plugins["entries"].(map[string]any) + require.True(t, ok, "plugins.entries should be present") + googleEntry, ok := entries["google"].(map[string]any) + require.True(t, ok, "plugins.entries.google should be present") + googleConfig, ok := googleEntry["config"].(map[string]any) + require.True(t, ok, "plugins.entries.google.config should be present") + webSearch, ok := googleConfig["webSearch"].(map[string]any) + require.True(t, ok, "plugins.entries.google.config.webSearch should be present") assert.Equal(t, "gemini-2.0-flash", webSearch["model"]) _, hasAPIKey := webSearch["apiKey"] assert.False(t, hasAPIKey, "gemini should not have placeholder apiKey")