Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,9 @@ ARG NEMOCLAW_PROXY_PORT=3128
# The actual API key is injected at runtime via openshell:resolve:env, never
# baked into the image.
ARG NEMOCLAW_WEB_SEARCH_ENABLED=0
# Web search provider: brave (default), gemini, or tavily.
# Controls which plugin entry and credential env var are written to openclaw.json.
ARG NEMOCLAW_WEB_SEARCH_PROVIDER=brave

# SECURITY: Promote build-args to env vars so the Python script reads them
# via os.environ, never via string interpolation into Python source code.
Expand All @@ -308,7 +311,8 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \
NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \
NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} \
NEMOCLAW_WEB_SEARCH_ENABLED=${NEMOCLAW_WEB_SEARCH_ENABLED}
NEMOCLAW_WEB_SEARCH_ENABLED=${NEMOCLAW_WEB_SEARCH_ENABLED} \
NEMOCLAW_WEB_SEARCH_PROVIDER=${NEMOCLAW_WEB_SEARCH_PROVIDER}

WORKDIR /sandbox
USER sandbox
Expand Down
22 changes: 22 additions & 0 deletions nemoclaw-blueprint/policies/presets/gemini.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

preset:
name: gemini
description: "Google Gemini API access for web search"

network_policies:
gemini:
name: gemini
endpoints:
- host: generativelanguage.googleapis.com
port: 443
protocol: rest
enforcement: enforce
tls: terminate
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: POST, path: "/**" }
binaries:
- { path: /usr/local/bin/node }
- { path: /usr/bin/node }
22 changes: 22 additions & 0 deletions nemoclaw-blueprint/policies/presets/tavily.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

preset:
name: tavily
description: "Tavily Search API access"

network_policies:
tavily:
name: tavily
endpoints:
- host: api.tavily.com
port: 443
protocol: rest
enforcement: enforce
tls: terminate
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: POST, path: "/**" }
binaries:
- { path: /usr/local/bin/node }
- { path: /usr/bin/node }
41 changes: 39 additions & 2 deletions scripts/generate-openclaw-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
NEMOCLAW_PROXY_HOST Egress proxy host (default: 10.200.0.1)
NEMOCLAW_PROXY_PORT Egress proxy port (default: 3128)
NEMOCLAW_WEB_SEARCH_ENABLED Set to "1" to enable web search tools
NEMOCLAW_WEB_SEARCH_PROVIDER Provider name: brave|gemini|tavily (default: brave)
"""

from __future__ import annotations
Expand Down Expand Up @@ -295,12 +296,48 @@ def _placeholder(channel: str, env_key: str) -> str:
}

if env.get("NEMOCLAW_WEB_SEARCH_ENABLED", "") == "1":
provider = env.get("NEMOCLAW_WEB_SEARCH_PROVIDER", "brave").strip().lower()
if provider not in ("brave", "gemini", "tavily"):
provider = "brave"

# Map provider to credential env var and plugin entry
provider_config = {
"brave": {
"credential_env": "BRAVE_API_KEY",
"plugin_entry": "brave",
},
"gemini": {
"credential_env": "GEMINI_API_KEY",
"plugin_entry": "google",
},
"tavily": {
"credential_env": "TAVILY_API_KEY",
"plugin_entry": "tavily",
},
}[provider]

credential_env = provider_config["credential_env"]
plugin_entry = provider_config["plugin_entry"]

# Build plugin config
web_search_plugin_config: dict = {
"apiKey": f"openshell:resolve:env:{credential_env}",
}
if provider == "gemini":
web_search_plugin_config["model"] = "gemini-2.5-flash"

config.setdefault("plugins", {}).setdefault("entries", {})[plugin_entry] = {
"enabled": True,
"config": {
"webSearch": web_search_plugin_config,
},
}

config["tools"] = {
"web": {
"search": {
"enabled": True,
"provider": "brave",
"apiKey": "openshell:resolve:env:BRAVE_API_KEY",
"provider": provider,
},
"fetch": {"enabled": True},
}
Expand Down
4 changes: 2 additions & 2 deletions src/lib/onboard-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,11 @@ describe("onboard session", () => {
it("persists and clears web search config through safe session updates", () => {
session.saveSession(session.createSession());
session.markStepComplete("provider_selection", {
webSearchConfig: { fetchEnabled: true },
webSearchConfig: { provider: "brave", fetchEnabled: true },
});

let loaded = requireLoadedSession(session.loadSession());
expect(loaded.webSearchConfig).toEqual({ fetchEnabled: true });
expect(loaded.webSearchConfig).toEqual({ provider: "brave", fetchEnabled: true });

session.completeSession({ webSearchConfig: null });
loaded = requireLoadedSession(session.loadSession());
Expand Down
23 changes: 13 additions & 10 deletions src/lib/onboard-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import path from "node:path";

import { redactSensitiveText, redactUrl } from "./redact";
import { isErrnoException } from "./errno";
import type { WebSearchConfig } from "./web-search";
import { normalizePersistedWebSearchConfig } from "./web-search";
import type { PersistedWebSearchConfig } from "./web-search";

export const SESSION_VERSION = 1;
export const SESSION_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw");
Expand Down Expand Up @@ -74,7 +75,7 @@ export interface Session {
credentialEnv: string | null;
preferredInferenceApi: string | null;
nimContainer: string | null;
webSearchConfig: WebSearchConfig | null;
webSearchConfig: PersistedWebSearchConfig | null;
policyPresets: string[] | null;
messagingChannels: string[] | null;
// SHA-256 hex digest of every legacy credential value successfully
Expand Down Expand Up @@ -116,7 +117,7 @@ export interface SessionUpdates {
credentialEnv?: string;
preferredInferenceApi?: string;
nimContainer?: string;
webSearchConfig?: WebSearchConfig | null;
webSearchConfig?: PersistedWebSearchConfig | null;
policyPresets?: string[];
messagingChannels?: string[];
migratedLegacyValueHashes?: Record<string, string>;
Expand Down Expand Up @@ -205,8 +206,8 @@ function readStepStatus(value: SessionJsonValue | undefined): StepStatus | null
return isStepStatus(value) ? value : null;
}

function parseWebSearchConfig(value: SessionJsonValue | undefined): WebSearchConfig | null {
return isObject(value) && value.fetchEnabled === true ? { fetchEnabled: true } : null;
function parseWebSearchConfig(value: SessionJsonValue | undefined): PersistedWebSearchConfig | null {
return normalizePersistedWebSearchConfig(value);
}

function parseSessionMetadata(value: SessionJsonValue | undefined): SessionMetadata | undefined {
Expand Down Expand Up @@ -281,8 +282,9 @@ export function createSession(overrides: Partial<Session> = {}): Session {
credentialEnv: overrides.credentialEnv ?? null,
preferredInferenceApi: overrides.preferredInferenceApi ?? null,
nimContainer: overrides.nimContainer ?? null,
webSearchConfig:
overrides.webSearchConfig?.fetchEnabled === true ? { fetchEnabled: true } : null,
webSearchConfig: overrides.webSearchConfig
? normalizePersistedWebSearchConfig(overrides.webSearchConfig) ?? null
: null,
policyPresets: readStringArray(overrides.policyPresets),
messagingChannels: readStringArray(overrides.messagingChannels),
migratedLegacyValueHashes: overrides.migratedLegacyValueHashes
Expand Down Expand Up @@ -615,10 +617,11 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial<Session> {
if (typeof updates.preferredInferenceApi === "string")
safe.preferredInferenceApi = updates.preferredInferenceApi;
if (typeof updates.nimContainer === "string") safe.nimContainer = updates.nimContainer;
if (isObject(updates.webSearchConfig) && updates.webSearchConfig.fetchEnabled === true) {
safe.webSearchConfig = { fetchEnabled: true };
} else if (updates.webSearchConfig === null) {
if (updates.webSearchConfig === null) {
safe.webSearchConfig = null;
} else if (isObject(updates.webSearchConfig)) {
const normalized = normalizePersistedWebSearchConfig(updates.webSearchConfig);
if (normalized) safe.webSearchConfig = normalized;
}
if (Array.isArray(updates.policyPresets)) {
safe.policyPresets = updates.policyPresets.filter((value) => typeof value === "string");
Expand Down
Loading
Loading