diff --git a/Dockerfile b/Dockerfile index 70815cc740e..5e0b8e25b84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,7 +61,6 @@ ARG CHAT_UI_URL=http://127.0.0.1:18789 ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 ARG NEMOCLAW_INFERENCE_API=openai-completions ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30= -ARG NEMOCLAW_WEB_CONFIG_B64=e30= # Base64-encoded JSON list of messaging channel names to pre-configure # (e.g. ["discord","telegram"]). Channels are added with placeholder tokens # so the L7 proxy can rewrite them at egress. Default: empty list. @@ -86,6 +85,11 @@ ARG NEMOCLAW_BUILD_ID=default # before running `nemoclaw onboard`. See #1409. ARG NEMOCLAW_PROXY_HOST=10.200.0.1 ARG NEMOCLAW_PROXY_PORT=3128 +# Non-secret flag: set to "1" when the user configured Brave Search during +# onboard. Controls whether the web search block is written to openclaw.json. +# The actual API key is injected at runtime via openshell:resolve:env, never +# baked into the image. +ARG NEMOCLAW_WEB_SEARCH_ENABLED=0 # SECURITY: Promote build-args to env vars so the Python script reads them # via os.environ, never via string interpolation into Python source code. @@ -97,13 +101,13 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} \ - NEMOCLAW_WEB_CONFIG_B64=${NEMOCLAW_WEB_CONFIG_B64} \ NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ - NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} + NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} \ + NEMOCLAW_WEB_SEARCH_ENABLED=${NEMOCLAW_WEB_SEARCH_ENABLED} WORKDIR /sandbox USER sandbox @@ -123,7 +127,6 @@ primary_model_ref = os.environ['NEMOCLAW_PRIMARY_MODEL_REF']; \ inference_base_url = os.environ['NEMOCLAW_INFERENCE_BASE_URL']; \ inference_api = os.environ['NEMOCLAW_INFERENCE_API']; \ inference_compat = json.loads(base64.b64decode(os.environ['NEMOCLAW_INFERENCE_COMPAT_B64']).decode('utf-8')); \ -web_config = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_WEB_CONFIG_B64', 'e30=') or 'e30=').decode('utf-8')); \ msg_channels = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_CHANNELS_B64', 'W10=') or 'W10=').decode('utf-8')); \ _allowed_ids = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_ALLOWED_IDS_B64', 'e30=') or 'e30=').decode('utf-8')); \ _discord_guilds = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_DISCORD_GUILDS_B64', 'e30=') or 'e30=').decode('utf-8')); \ @@ -166,14 +169,12 @@ config.update({ \ 'search': { \ 'enabled': True, \ 'provider': 'brave', \ - **({'apiKey': web_config.get('apiKey', '')} if web_config.get('apiKey', '') else {}) \ + 'apiKey': 'openshell:resolve:env:BRAVE_API_KEY' \ }, \ - 'fetch': { \ - 'enabled': bool(web_config.get('fetchEnabled', True)) \ - } \ + 'fetch': {'enabled': True} \ } \ } \ -} if web_config.get('provider') == 'brave' else {}); \ +}) if os.environ.get('NEMOCLAW_WEB_SEARCH_ENABLED', '') == '1' else None; \ path = os.path.expanduser('~/.openclaw/openclaw.json'); \ json.dump(config, open(path, 'w'), indent=2); \ os.chmod(path, 0o600)" diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 35604639784..da221bab8b1 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1038,8 +1038,8 @@ function patchStagedDockerfile( ); } dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_WEB_CONFIG_B64=.*$/m, - `ARG NEMOCLAW_WEB_CONFIG_B64=${webSearch.buildWebSearchDockerConfig(webSearchConfig)}`, + /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=.*$/m, + `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${webSearchConfig ? "1" : "0"}`, ); // Onboard flow expects immediate dashboard access without device pairing, // so disable device auth for images built during onboard (see #1217). @@ -2705,6 +2705,12 @@ async function createSandbox( // end-to-end with the stricter filtering. The allowlist rejects unknown // env vars by default, which is safer but needs careful rollout. const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; + if (webSearchConfig?.fetchEnabled) { + const braveKey = getCredential(webSearch.BRAVE_API_KEY_ENV) || process.env[webSearch.BRAVE_API_KEY_ENV]; + if (braveKey) { + envArgs.push(formatEnvAssignment(webSearch.BRAVE_API_KEY_ENV, braveKey)); + } + } const blockedSandboxEnvNames = new Set([ // Derived from REMOTE_PROVIDER_CONFIG to prevent drift ...Object.values(REMOTE_PROVIDER_CONFIG) diff --git a/src/lib/web-search.test.ts b/src/lib/web-search.test.ts index 8ac611c23b3..cf98d55d781 100644 --- a/src/lib/web-search.test.ts +++ b/src/lib/web-search.test.ts @@ -3,32 +3,10 @@ import { describe, expect, it } from "vitest"; -import { - buildWebSearchDockerConfig, -} from "./web-search"; +import { BRAVE_API_KEY_ENV } from "./web-search"; -describe("web-search helpers", () => { - it("emits empty docker config when web search is disabled", () => { - expect(Buffer.from(buildWebSearchDockerConfig(null), "base64").toString("utf8")).toBe( - "{}", - ); - }); - - it("emits empty docker config when fetchEnabled is false", () => { - expect( - Buffer.from( - buildWebSearchDockerConfig({ fetchEnabled: false }), - "base64", - ).toString("utf8"), - ).toBe("{}"); - }); - - it("encodes Brave Search docker config using proxy placeholder for api key", () => { - const encoded = buildWebSearchDockerConfig({ fetchEnabled: true }); - expect(JSON.parse(Buffer.from(encoded, "base64").toString("utf8"))).toEqual({ - provider: "brave", - fetchEnabled: true, - apiKey: "openshell:resolve:env:BRAVE_API_KEY", - }); +describe("web-search module", () => { + it("exports BRAVE_API_KEY_ENV constant", () => { + expect(BRAVE_API_KEY_ENV).toBe("BRAVE_API_KEY"); }); }); diff --git a/src/lib/web-search.ts b/src/lib/web-search.ts index fc31f390090..dd6d7682ac9 100644 --- a/src/lib/web-search.ts +++ b/src/lib/web-search.ts @@ -6,22 +6,3 @@ export interface WebSearchConfig { } export const BRAVE_API_KEY_ENV = "BRAVE_API_KEY"; - -export function encodeDockerJsonArg(value: unknown): string { - return Buffer.from(JSON.stringify(value ?? {}), "utf8").toString("base64"); -} - -export function buildWebSearchDockerConfig( - config: WebSearchConfig | null, -): string { - if (!config || config.fetchEnabled !== true) return encodeDockerJsonArg({}); - - const payload = { - provider: "brave", - fetchEnabled: Boolean(config.fetchEnabled), - // Use the OpenShell proxy placeholder instead of the raw API key to ensure - // credentials are never baked into Docker images or raw sandbox configuration. - apiKey: `openshell:resolve:env:${BRAVE_API_KEY_ENV}`, - }; - return encodeDockerJsonArg(payload); -} diff --git a/test/onboard.test.ts b/test/onboard.test.ts index de6782f51b0..0f18b63703c 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -151,7 +151,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -186,7 +186,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", @@ -244,7 +244,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", @@ -437,7 +437,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -474,7 +474,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", "ARG NEMOCLAW_BUILD_ID=default", "ARG NEMOCLAW_PROXY_HOST=10.200.0.1", "ARG NEMOCLAW_PROXY_PORT=3128", @@ -526,7 +526,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", "ARG NEMOCLAW_BUILD_ID=default", "ARG NEMOCLAW_PROXY_HOST=10.200.0.1", "ARG NEMOCLAW_PROXY_PORT=3128", @@ -569,7 +569,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", "ARG NEMOCLAW_BUILD_ID=default", "ARG NEMOCLAW_PROXY_HOST=10.200.0.1", "ARG NEMOCLAW_PROXY_PORT=3128", @@ -621,7 +621,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -639,14 +639,9 @@ describe("onboard helpers", () => { { fetchEnabled: true }, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); - const expected = buildWebSearchDockerConfig({ fetchEnabled: true }); - assert.match( - patched, - new RegExp( - `^ARG NEMOCLAW_WEB_CONFIG_B64=${expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, - "m", - ), - ); + assert.match(patched, /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=1$/m); + // Regression guard: the old secret-bearing build arg must not reappear. + assert.doesNotMatch(patched, /NEMOCLAW_WEB_CONFIG_B64/); } finally { if (priorBraveKey === undefined) { delete process.env.BRAVE_API_KEY;