From 1f3f0ff46731cfe06b8fd0e5de90a1614b00e4ba Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Sun, 5 Apr 2026 10:45:22 +0800 Subject: [PATCH 1/5] feat(onboard): expand web search onboarding beyond Brave Signed-off-by: 13ernkastel --- Dockerfile | 15 +- bin/lib/onboard.js | 267 +++++++++++++----- docs/reference/commands.md | 6 +- .../policies/presets/gemini.yaml | 22 ++ .../policies/presets/tavily.yaml | 22 ++ src/lib/onboard-session.test.ts | 23 +- src/lib/onboard-session.ts | 34 ++- src/lib/web-search.test.ts | 76 ++++- src/lib/web-search.ts | 122 +++++++- test/onboard.test.js | 59 +++- test/policies.test.js | 15 +- 11 files changed, 540 insertions(+), 121 deletions(-) create mode 100644 nemoclaw-blueprint/policies/presets/gemini.yaml create mode 100644 nemoclaw-blueprint/policies/presets/tavily.yaml diff --git a/Dockerfile b/Dockerfile index adef7f1d4..c9f233c5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -123,20 +123,7 @@ config = { \ 'auth': {'token': secrets.token_hex(32)} \ } \ }; \ -config.update({ \ - 'tools': { \ - 'web': { \ - 'search': { \ - 'enabled': True, \ - 'provider': 'brave', \ - **({'apiKey': web_config.get('apiKey', '')} if web_config.get('apiKey', '') else {}) \ - }, \ - 'fetch': { \ - 'enabled': bool(web_config.get('fetchEnabled', True)) \ - } \ - } \ - } \ -} if web_config.get('provider') == 'brave' else {}); \ +config.update(web_config if isinstance(web_config, dict) else {}); \ path = os.path.expanduser('~/.openclaw/openclaw.json'); \ json.dump(config, open(path, 'w'), indent=2); \ os.chmod(path, 0o600)" diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 40a8c0855..7919623e9 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -100,7 +100,6 @@ const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; const OPENAI_ENDPOINT_URL = "https://api.openai.com/v1"; const ANTHROPIC_ENDPOINT_URL = "https://api.anthropic.com"; const GEMINI_ENDPOINT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; -const BRAVE_SEARCH_HELP_URL = "https://api.search.brave.com/app/keys"; const REMOTE_PROVIDER_CONFIG = { build: { @@ -982,9 +981,17 @@ function isAffirmativeAnswer(value) { ); } -function printBraveExposureWarning() { +function normalizeWebSearchConfigValue(config) { + return webSearch.normalizeWebSearchConfig(config); +} + +function getWebSearchProviderMetadata(provider) { + return webSearch.getWebSearchProvider(provider); +} + +function printWebSearchExposureWarning(provider) { console.log(""); - for (const line of webSearch.getBraveExposureWarningLines()) { + for (const line of webSearch.getWebSearchExposureWarningLines(provider)) { console.log(` ${line}`); } console.log(""); @@ -1009,118 +1016,231 @@ function validateBraveSearchApiKey(apiKey) { ]); } -async function promptBraveSearchRecovery(validation) { - const recovery = classifyValidationFailure(validation); +function validateGeminiSearchApiKey(apiKey) { + return probeOpenAiLikeEndpoint( + GEMINI_ENDPOINT_URL, + webSearch.DEFAULT_GEMINI_WEB_SEARCH_MODEL, + apiKey, + ); +} + +function validateTavilySearchApiKey(apiKey) { + return runCurlProbe([ + "-sS", + "--compressed", + "-H", + "Content-Type: application/json", + "-H", + `Authorization: Bearer ${apiKey}`, + "-d", + JSON.stringify({ + query: "ping", + max_results: 1, + }), + "https://api.tavily.com/search", + ]); +} + +function validateWebSearchCredential(provider, apiKey) { + switch (provider) { + case "gemini": + return validateGeminiSearchApiKey(apiKey); + case "tavily": + return validateTavilySearchApiKey(apiKey); + case "brave": + default: + return validateBraveSearchApiKey(apiKey); + } +} + +function getWebSearchValidationRecovery(validation) { + if (Array.isArray(validation?.failures)) { + return getProbeRecovery(validation); + } + return classifyValidationFailure(validation); +} + +async function promptWebSearchRecovery(provider, validation) { + const { label } = getWebSearchProviderMetadata(provider); + const recovery = getWebSearchValidationRecovery(validation); if (recovery.kind === "credential") { - console.log(" Brave Search rejected that API key."); + console.log(` ${label} rejected that API key.`); } else if (recovery.kind === "transport") { - console.log(getTransportRecoveryMessage(validation)); + console.log(getTransportRecoveryMessage(recovery.failure || validation)); } else { - console.log(" Brave Search validation did not succeed."); + console.log(` ${label} validation did not succeed.`); } - const answer = (await prompt(" Type 'retry', 'skip', or 'exit' [retry]: ")).trim().toLowerCase(); - if (answer === "skip") return "skip"; + const answer = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")).trim().toLowerCase(); + if (answer === "back") return "back"; if (answer === "exit" || answer === "quit") { exitOnboardFromPrompt(); } return "retry"; } -async function promptBraveSearchApiKey() { +async function promptWebSearchApiKey(provider) { + const { label, helpUrl } = getWebSearchProviderMetadata(provider); console.log(""); - console.log(` Get your Brave Search API key from: ${BRAVE_SEARCH_HELP_URL}`); + console.log(` Get your ${label} API key from: ${helpUrl}`); console.log(""); while (true) { - const key = normalizeCredentialValue( - await prompt(" Brave Search API key: ", { secret: true }), - ); + const key = normalizeCredentialValue(await prompt(` ${label} API key: `, { secret: true })); if (!key) { - console.error(" Brave Search API key is required."); + console.error(` ${label} API key is required.`); continue; } return key; } } -async function ensureValidatedBraveSearchCredential() { - let apiKey = getCredential(webSearch.BRAVE_API_KEY_ENV); +async function ensureValidatedWebSearchCredential(provider) { + const { credentialEnv, label } = getWebSearchProviderMetadata(provider); + let apiKey = getCredential(credentialEnv); let usingSavedKey = Boolean(apiKey); while (true) { if (!apiKey) { - apiKey = await promptBraveSearchApiKey(); + apiKey = await promptWebSearchApiKey(provider); usingSavedKey = false; } - const validation = validateBraveSearchApiKey(apiKey); + const validation = validateWebSearchCredential(provider, apiKey); if (validation.ok) { - saveCredential(webSearch.BRAVE_API_KEY_ENV, apiKey); - process.env[webSearch.BRAVE_API_KEY_ENV] = apiKey; + saveCredential(credentialEnv, apiKey); + process.env[credentialEnv] = apiKey; return apiKey; } const prefix = usingSavedKey - ? " Saved Brave Search API key validation failed." - : " Brave Search API key validation failed."; + ? ` Saved ${label} API key validation failed.` + : ` ${label} API key validation failed.`; console.error(prefix); if (validation.message) { console.error(` ${validation.message}`); } - const action = await promptBraveSearchRecovery(validation); - if (action === "skip") { - console.log(" Skipping Brave Web Search setup."); - console.log(""); - return null; - } + const action = await promptWebSearchRecovery(provider, validation); + if (action === "back") return null; apiKey = null; usingSavedKey = false; } } +async function promptWebSearchProviderChoice() { + const providers = webSearch.listWebSearchProviders(); + + while (true) { + console.log(""); + console.log(" Web search providers:"); + providers.forEach((provider, index) => { + console.log(` ${index + 1}) ${provider.label}`); + }); + console.log(` ${providers.length + 1}) Skip`); + console.log(""); + + const answer = (await prompt(` Choose [${providers.length + 1}]: `)).trim().toLowerCase(); + if (!answer || answer === String(providers.length + 1) || answer === "skip") { + return null; + } + + const providerIndex = Number(answer); + if ( + Number.isInteger(providerIndex) && + providerIndex >= 1 && + providerIndex <= providers.length + ) { + return providers[providerIndex - 1].provider; + } + + const parsedProvider = webSearch.parseWebSearchProvider(answer); + if (parsedProvider) { + return parsedProvider; + } + + console.error(" Invalid choice. Enter a number or provider name, or choose Skip."); + } +} + +function resolveNonInteractiveWebSearchProvider() { + const configuredProvider = process.env[webSearch.WEB_SEARCH_PROVIDER_ENV]; + if (configuredProvider) { + const parsedProvider = webSearch.parseWebSearchProvider(configuredProvider); + if (!parsedProvider) { + console.error( + ` ${webSearch.WEB_SEARCH_PROVIDER_ENV} must be one of: brave, gemini, tavily.`, + ); + process.exit(1); + } + return parsedProvider; + } + + for (const provider of webSearch.listWebSearchProviders()) { + if (normalizeCredentialValue(process.env[provider.credentialEnv])) { + return provider.provider; + } + } + return null; +} + async function configureWebSearch(existingConfig = null) { - if (existingConfig) { - return { fetchEnabled: true }; + const normalizedExistingConfig = normalizeWebSearchConfigValue(existingConfig); + if (normalizedExistingConfig) { + return normalizedExistingConfig; } if (isNonInteractive()) { - const braveApiKey = normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); - if (!braveApiKey) { + const provider = resolveNonInteractiveWebSearchProvider(); + if (!provider) { return null; } - note(" [non-interactive] Brave Web Search requested."); - printBraveExposureWarning(); - const validation = validateBraveSearchApiKey(braveApiKey); + const { credentialEnv, label } = getWebSearchProviderMetadata(provider); + const apiKey = normalizeCredentialValue(process.env[credentialEnv]); + if (!apiKey) { + console.error(` ${credentialEnv} is required for ${label} in non-interactive mode.`); + process.exit(1); + } + note(` [non-interactive] ${label} requested.`); + printWebSearchExposureWarning(provider); + const validation = validateWebSearchCredential(provider, apiKey); if (!validation.ok) { - console.error(" Brave Search API key validation failed."); + console.error(` ${label} API key validation failed.`); if (validation.message) { console.error(` ${validation.message}`); } process.exit(1); } - saveCredential(webSearch.BRAVE_API_KEY_ENV, braveApiKey); - process.env[webSearch.BRAVE_API_KEY_ENV] = braveApiKey; - return { fetchEnabled: true }; + saveCredential(credentialEnv, apiKey); + process.env[credentialEnv] = apiKey; + return { provider, fetchEnabled: true }; } - printBraveExposureWarning(); - const enableAnswer = await prompt(" Enable Brave Web Search? [y/N]: "); + const enableAnswer = await prompt(" Enable Web Search? [y/N]: "); if (!isAffirmativeAnswer(enableAnswer)) { return null; } - const braveApiKey = await ensureValidatedBraveSearchCredential(); - if (!braveApiKey) { - return null; - } + while (true) { + const provider = await promptWebSearchProviderChoice(); + if (!provider) { + return null; + } - console.log(" ✓ Enabled Brave Web Search"); - console.log(""); - return { fetchEnabled: true }; + printWebSearchExposureWarning(provider); + const apiKey = await ensureValidatedWebSearchCredential(provider); + if (!apiKey) { + console.log(" Returning to web search provider selection."); + console.log(""); + continue; + } + + console.log(` ✓ Enabled ${getWebSearchProviderMetadata(provider).label}`); + console.log(""); + return { provider, fetchEnabled: true }; + } } function getSandboxInferenceConfig(model, provider = null, preferredInferenceApi = null) { @@ -1178,6 +1298,7 @@ function patchStagedDockerfile( ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); + const normalizedWebSearchConfig = normalizeWebSearchConfigValue(webSearchConfig); let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); dockerfile = dockerfile.replace(/^ARG NEMOCLAW_MODEL=.*$/m, `ARG NEMOCLAW_MODEL=${model}`); dockerfile = dockerfile.replace( @@ -1208,8 +1329,12 @@ function patchStagedDockerfile( dockerfile = dockerfile.replace( /^ARG NEMOCLAW_WEB_CONFIG_B64=.*$/m, `ARG NEMOCLAW_WEB_CONFIG_B64=${webSearch.buildWebSearchDockerConfig( - webSearchConfig, - webSearchConfig ? getCredential(webSearch.BRAVE_API_KEY_ENV) : null, + normalizedWebSearchConfig, + normalizedWebSearchConfig + ? getCredential( + getWebSearchProviderMetadata(normalizedWebSearchConfig.provider).credentialEnv, + ) + : null, )}`, ); // Onboard flow expects immediate dashboard access without device pairing, @@ -2500,12 +2625,20 @@ async function createSandbox( // --gpu is intentionally omitted. See comment in startGateway(). console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); - if (webSearchConfig && !getCredential(webSearch.BRAVE_API_KEY_ENV)) { - console.error(" Brave Search is enabled, but BRAVE_API_KEY is not available in this process."); - console.error( - " Re-run with BRAVE_API_KEY set, or disable Brave Search before recreating the sandbox.", + const normalizedWebSearchConfig = normalizeWebSearchConfigValue(webSearchConfig); + if (normalizedWebSearchConfig) { + const { credentialEnv, label } = getWebSearchProviderMetadata( + normalizedWebSearchConfig.provider, ); - process.exit(1); + if (!getCredential(credentialEnv)) { + console.error( + ` ${label} is enabled, but ${credentialEnv} is not available in this process.`, + ); + console.error( + ` Re-run with ${credentialEnv} set, or disable ${label} before recreating the sandbox.`, + ); + process.exit(1); + } } patchStagedDockerfile( stagedDockerfile, @@ -3543,7 +3676,7 @@ function arePolicyPresetsApplied(sandboxName, selectedPresets = []) { async function setupPoliciesWithSelection(sandboxName, options = {}) { const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; - const webSearchConfig = options.webSearchConfig || null; + const webSearchConfig = normalizeWebSearchConfigValue(options.webSearchConfig || null); step(7, 7, "Policy presets"); @@ -3552,7 +3685,9 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { if (getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN) suggestions.push("slack"); if (getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) suggestions.push("discord"); - if (webSearchConfig) suggestions.push("brave"); + if (webSearchConfig) { + suggestions.push(getWebSearchProviderMetadata(webSearchConfig.provider).policyPreset); + } const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -4057,16 +4192,18 @@ async function onboard(opts = {}) { break; } - if (webSearchConfig) { - note(" [resume] Revalidating Brave Search configuration."); - const braveApiKey = await ensureValidatedBraveSearchCredential(); - if (braveApiKey) { - webSearchConfig = { fetchEnabled: true }; + const persistedWebSearchConfig = normalizeWebSearchConfigValue(webSearchConfig); + if (persistedWebSearchConfig) { + const { label } = getWebSearchProviderMetadata(persistedWebSearchConfig.provider); + note(` [resume] Revalidating ${label} configuration.`); + const apiKey = await ensureValidatedWebSearchCredential(persistedWebSearchConfig.provider); + if (apiKey) { + webSearchConfig = persistedWebSearchConfig; onboardSession.updateSession((current) => { current.webSearchConfig = webSearchConfig; return current; }); - note(" [resume] Reusing Brave Search configuration."); + note(` [resume] Reusing ${label} configuration.`); } else { webSearchConfig = await configureWebSearch(null); onboardSession.updateSession((current) => { diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c312c6991..07a75f1dc 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -88,14 +88,16 @@ or: $ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 nemoclaw onboard --non-interactive ``` -To enable Brave Search in non-interactive mode, set: +To enable web search in non-interactive mode, set a supported provider key: ```console $ BRAVE_API_KEY=... \ nemoclaw onboard --non-interactive ``` -`BRAVE_API_KEY` enables Brave Search in non-interactive mode and also enables `web_fetch`. +Supported keys are `BRAVE_API_KEY`, `GEMINI_API_KEY`, and `TAVILY_API_KEY`. +If more than one is set, NemoClaw prefers Brave, then Gemini, then Tavily unless `NEMOCLAW_WEB_SEARCH_PROVIDER` is set explicitly to `brave`, `gemini`, or `tavily`. +Enabling web search also enables `web_fetch`. The wizard prompts for a sandbox name. Names must follow RFC 1123 subdomain rules: lowercase alphanumeric characters and hyphens only, and must start and end with an alphanumeric character. diff --git a/nemoclaw-blueprint/policies/presets/gemini.yaml b/nemoclaw-blueprint/policies/presets/gemini.yaml new file mode 100644 index 000000000..508f886b6 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/gemini.yaml @@ -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 web search API access" + +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 } diff --git a/nemoclaw-blueprint/policies/presets/tavily.yaml b/nemoclaw-blueprint/policies/presets/tavily.yaml new file mode 100644 index 000000000..66343a679 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/tavily.yaml @@ -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 } diff --git a/src/lib/onboard-session.test.ts b/src/lib/onboard-session.test.ts index 6156a574e..2e215d111 100644 --- a/src/lib/onboard-session.test.ts +++ b/src/lib/onboard-session.test.ts @@ -120,6 +120,17 @@ describe("onboard session", () => { expect(loaded.metadata.token).toBeUndefined(); }); + it("normalizes legacy web search configs and clears explicit disable updates", () => { + session.saveSession(session.createSession({ webSearchConfig: { fetchEnabled: true } })); + + let loaded = session.loadSession(); + expect(loaded.webSearchConfig).toEqual({ provider: "brave", fetchEnabled: true }); + + session.completeSession({ webSearchConfig: { provider: "brave", fetchEnabled: false } }); + loaded = session.loadSession(); + expect(loaded.webSearchConfig).toBeNull(); + }); + it("does not clear existing metadata when updates omit whitelisted metadata fields", () => { session.saveSession(session.createSession({ metadata: { gatewayName: "nemoclaw" } })); session.markStepComplete("provider_selection", { @@ -193,13 +204,15 @@ describe("onboard session", () => { session.saveSession(session.createSession()); session.markStepFailed( "inference", - "provider auth failed with NVIDIA_API_KEY=nvapi-secret Bearer topsecret sk-secret-value ghp_1234567890123456789012345", + "provider auth failed with NVIDIA_API_KEY=nvapi-secret TAVILY_API_KEY=tvly-secret Bearer topsecret sk-secret-value ghp_1234567890123456789012345", ); const loaded = session.loadSession(); expect(loaded.steps.inference.error).toContain("NVIDIA_API_KEY="); + expect(loaded.steps.inference.error).toContain("TAVILY_API_KEY="); expect(loaded.steps.inference.error).toContain("Bearer "); expect(loaded.steps.inference.error).not.toContain("nvapi-secret"); + expect(loaded.steps.inference.error).not.toContain("tvly-secret"); expect(loaded.steps.inference.error).not.toContain("topsecret"); expect(loaded.steps.inference.error).not.toContain("sk-secret-value"); expect(loaded.steps.inference.error).not.toContain("ghp_1234567890123456789012345"); @@ -207,13 +220,19 @@ describe("onboard session", () => { }); it("summarizes the session for debug output", () => { - session.saveSession(session.createSession({ sandboxName: "my-assistant" })); + session.saveSession( + session.createSession({ + sandboxName: "my-assistant", + webSearchConfig: { provider: "tavily", fetchEnabled: true }, + }), + ); session.markStepStarted("preflight"); session.markStepComplete("preflight"); session.completeSession(); const summary = session.summarizeForDebug(); expect(summary.sandboxName).toBe("my-assistant"); + expect(summary.webSearchConfig).toEqual({ provider: "tavily", fetchEnabled: true }); expect(summary.steps.preflight.status).toBe("complete"); expect(summary.steps.preflight.startedAt).toBeTruthy(); expect(summary.steps.preflight.completedAt).toBeTruthy(); diff --git a/src/lib/onboard-session.ts b/src/lib/onboard-session.ts index 588d38bbb..b9ca5badd 100644 --- a/src/lib/onboard-session.ts +++ b/src/lib/onboard-session.ts @@ -10,7 +10,7 @@ import fs from "node:fs"; import path from "node:path"; -import type { WebSearchConfig } from "./web-search"; +import { normalizeWebSearchConfig, type WebSearchConfig } from "./web-search"; export const SESSION_VERSION = 1; export const SESSION_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw"); @@ -123,7 +123,7 @@ export function redactSensitiveText(value: unknown): string | null { if (typeof value !== "string") return null; return value .replace( - /(NVIDIA_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|COMPATIBLE_API_KEY|COMPATIBLE_ANTHROPIC_API_KEY|BRAVE_API_KEY)=\S+/gi, + /(NVIDIA_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|COMPATIBLE_API_KEY|COMPATIBLE_ANTHROPIC_API_KEY|BRAVE_API_KEY|TAVILY_API_KEY)=\S+/gi, "$1=", ) .replace(/Bearer\s+\S+/gi, "Bearer ") @@ -192,10 +192,7 @@ export function createSession(overrides: Partial = {}): Session { credentialEnv: overrides.credentialEnv || null, preferredInferenceApi: overrides.preferredInferenceApi || null, nimContainer: overrides.nimContainer || null, - webSearchConfig: - overrides.webSearchConfig && overrides.webSearchConfig.fetchEnabled === true - ? { fetchEnabled: true } - : null, + webSearchConfig: normalizeWebSearchConfig(overrides.webSearchConfig), policyPresets: Array.isArray(overrides.policyPresets) ? overrides.policyPresets.filter((value) => typeof value === "string") : null, @@ -226,11 +223,7 @@ export function normalizeSession(data: unknown): Session | null { preferredInferenceApi: typeof d.preferredInferenceApi === "string" ? d.preferredInferenceApi : null, nimContainer: typeof d.nimContainer === "string" ? d.nimContainer : null, - webSearchConfig: - isObject(d.webSearchConfig) && - (d.webSearchConfig as Record).fetchEnabled === true - ? { fetchEnabled: true } - : null, + webSearchConfig: normalizeWebSearchConfig(d.webSearchConfig), policyPresets: Array.isArray(d.policyPresets) ? (d.policyPresets as unknown[]).filter((value) => typeof value === "string") as string[] : null, @@ -413,10 +406,20 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { 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) { - safe.webSearchConfig = null; + if (Object.prototype.hasOwnProperty.call(updates, "webSearchConfig")) { + if (updates.webSearchConfig === null) { + safe.webSearchConfig = null; + } else { + const normalizedWebSearchConfig = normalizeWebSearchConfig(updates.webSearchConfig); + if (normalizedWebSearchConfig) { + safe.webSearchConfig = normalizedWebSearchConfig; + } else if ( + isObject(updates.webSearchConfig) && + updates.webSearchConfig.fetchEnabled === false + ) { + safe.webSearchConfig = null; + } + } } if (Array.isArray(updates.policyPresets)) { safe.policyPresets = updates.policyPresets.filter((value) => typeof value === "string"); @@ -511,6 +514,7 @@ export function summarizeForDebug(session: Session | null = loadSession()): Reco credentialEnv: session.credentialEnv, preferredInferenceApi: session.preferredInferenceApi, nimContainer: session.nimContainer, + webSearchConfig: session.webSearchConfig, policyPresets: session.policyPresets, lastStepStarted: session.lastStepStarted, lastCompletedStep: session.lastCompletedStep, diff --git a/src/lib/web-search.test.ts b/src/lib/web-search.test.ts index 7edae02ec..bb8adeda2 100644 --- a/src/lib/web-search.test.ts +++ b/src/lib/web-search.test.ts @@ -4,8 +4,10 @@ import { describe, expect, it } from "vitest"; import { + buildWebSearchConfigFragment, buildWebSearchDockerConfig, - getBraveExposureWarningLines, + getWebSearchExposureWarningLines, + normalizeWebSearchConfig, } from "./web-search"; describe("web-search helpers", () => { @@ -18,23 +20,81 @@ describe("web-search helpers", () => { it("emits empty docker config when fetchEnabled is false", () => { expect( Buffer.from( - buildWebSearchDockerConfig({ fetchEnabled: false }, null), + buildWebSearchDockerConfig({ provider: "brave", fetchEnabled: false }, null), "base64", ).toString("utf8"), ).toBe("{}"); }); - it("encodes Brave Search docker config including the api key", () => { - const encoded = buildWebSearchDockerConfig({ fetchEnabled: true }, "brv-x"); - expect(JSON.parse(Buffer.from(encoded, "base64").toString("utf8"))).toEqual({ + it("normalizes legacy Brave configs without an explicit provider", () => { + expect(normalizeWebSearchConfig({ fetchEnabled: true })).toEqual({ provider: "brave", fetchEnabled: true, - apiKey: "brv-x", }); }); - it("includes the explicit exposure caveat in the warning text", () => { - const warning = getBraveExposureWarningLines().join(" "); + it("builds the Brave Search OpenClaw config fragment", () => { + expect( + buildWebSearchConfigFragment({ provider: "brave", fetchEnabled: true }, "brv-x"), + ).toEqual({ + plugins: { + entries: { + brave: { + enabled: true, + config: { + webSearch: { + apiKey: "brv-x", + }, + }, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: "brave", + }, + fetch: { + enabled: true, + }, + }, + }, + }); + }); + + it("encodes Gemini Search docker config using the Google plugin entry", () => { + const encoded = buildWebSearchDockerConfig({ provider: "gemini", fetchEnabled: true }, "g-x"); + expect(JSON.parse(Buffer.from(encoded, "base64").toString("utf8"))).toEqual({ + plugins: { + entries: { + google: { + enabled: true, + config: { + webSearch: { + apiKey: "g-x", + }, + }, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: "gemini", + }, + fetch: { + enabled: true, + }, + }, + }, + }); + }); + + it("includes provider-specific exposure caveats in the warning text", () => { + const warning = getWebSearchExposureWarningLines("tavily").join(" "); + expect(warning).toContain("Tavily API key"); expect(warning).toContain("sandbox OpenClaw config"); expect(warning).toContain("OpenClaw agent will be able to read"); }); diff --git a/src/lib/web-search.ts b/src/lib/web-search.ts index 58676ddaf..d1fb2918f 100644 --- a/src/lib/web-search.ts +++ b/src/lib/web-search.ts @@ -1,33 +1,133 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +export type WebSearchProvider = "brave" | "gemini" | "tavily"; + export interface WebSearchConfig { + provider: WebSearchProvider; fetchEnabled: boolean; } +export interface WebSearchProviderMetadata { + provider: WebSearchProvider; + label: string; + helpUrl: string; + credentialEnv: string; + pluginEntry: string; + policyPreset: string; +} + export const BRAVE_API_KEY_ENV = "BRAVE_API_KEY"; +export const GEMINI_API_KEY_ENV = "GEMINI_API_KEY"; +export const TAVILY_API_KEY_ENV = "TAVILY_API_KEY"; +export const WEB_SEARCH_PROVIDER_ENV = "NEMOCLAW_WEB_SEARCH_PROVIDER"; +export const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash"; + +const WEB_SEARCH_PROVIDERS: Record = { + brave: { + provider: "brave", + label: "Brave Search", + helpUrl: "https://api.search.brave.com/app/keys", + credentialEnv: BRAVE_API_KEY_ENV, + pluginEntry: "brave", + policyPreset: "brave", + }, + gemini: { + provider: "gemini", + label: "Google Gemini", + helpUrl: "https://aistudio.google.com/app/apikey", + credentialEnv: GEMINI_API_KEY_ENV, + pluginEntry: "google", + policyPreset: "gemini", + }, + tavily: { + provider: "tavily", + label: "Tavily", + helpUrl: "https://app.tavily.com", + credentialEnv: TAVILY_API_KEY_ENV, + pluginEntry: "tavily", + policyPreset: "tavily", + }, +}; export function encodeDockerJsonArg(value: unknown): string { return Buffer.from(JSON.stringify(value ?? {}), "utf8").toString("base64"); } -export function getBraveExposureWarningLines(): string[] { +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function listWebSearchProviders(): WebSearchProviderMetadata[] { + return Object.values(WEB_SEARCH_PROVIDERS); +} + +export function parseWebSearchProvider(value: unknown): WebSearchProvider | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + return Object.hasOwn(WEB_SEARCH_PROVIDERS, normalized) + ? (normalized as WebSearchProvider) + : null; +} + +export function getWebSearchProvider(provider: WebSearchProvider): WebSearchProviderMetadata { + return WEB_SEARCH_PROVIDERS[provider]; +} + +export function normalizeWebSearchConfig(value: unknown): WebSearchConfig | null { + if (!isObject(value) || value.fetchEnabled !== true) return null; + return { + provider: parseWebSearchProvider(value.provider) || "brave", + fetchEnabled: true, + }; +} + +export function getWebSearchExposureWarningLines(provider: WebSearchProvider): string[] { + const { label } = getWebSearchProvider(provider); return [ - "NemoClaw will store the Brave API key in sandbox OpenClaw config.", + `NemoClaw will store the ${label} API key in sandbox OpenClaw config.`, "The OpenClaw agent will be able to read that key.", ]; } -export function buildWebSearchDockerConfig( +export function buildWebSearchConfigFragment( config: WebSearchConfig | null, - braveApiKey: string | null, -): string { - if (!config || config.fetchEnabled !== true) return encodeDockerJsonArg({}); + apiKey: string | null, +): Record { + const normalized = normalizeWebSearchConfig(config); + if (!normalized) return {}; - const payload = { - provider: "brave", - fetchEnabled: Boolean(config.fetchEnabled), - apiKey: braveApiKey || "", + const { pluginEntry } = getWebSearchProvider(normalized.provider); + return { + plugins: { + entries: { + [pluginEntry]: { + enabled: true, + config: { + webSearch: { + ...(apiKey ? { apiKey } : {}), + }, + }, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: normalized.provider, + }, + fetch: { + enabled: true, + }, + }, + }, }; - return encodeDockerJsonArg(payload); +} + +export function buildWebSearchDockerConfig( + config: WebSearchConfig | null, + apiKey: string | null, +): string { + return encodeDockerJsonArg(buildWebSearchConfigFragment(config, apiKey)); } diff --git a/test/onboard.test.js b/test/onboard.test.js index b6f0a858f..3e8d320e3 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -250,10 +250,13 @@ describe("onboard helpers", () => { "build-web", "openai-api", null, - { fetchEnabled: true }, + { provider: "brave", fetchEnabled: true }, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); - const expected = buildWebSearchDockerConfig({ fetchEnabled: true }, "brv-test-key"); + const expected = buildWebSearchDockerConfig( + { provider: "brave", fetchEnabled: true }, + "brv-test-key", + ); assert.match( patched, new RegExp( @@ -271,6 +274,58 @@ describe("onboard helpers", () => { } }); + it("patches the staged Dockerfile with Gemini Search config when enabled", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-web-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "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=", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n"), + ); + + const priorGeminiKey = process.env.GEMINI_API_KEY; + process.env.GEMINI_API_KEY = "gem-test-key"; + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + "build-web", + "openai-api", + null, + { provider: "gemini", fetchEnabled: true }, + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + const expected = buildWebSearchDockerConfig( + { provider: "gemini", fetchEnabled: true }, + "gem-test-key", + ); + assert.match( + patched, + new RegExp( + `^ARG NEMOCLAW_WEB_CONFIG_B64=${expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, + "m", + ), + ); + } finally { + if (priorGeminiKey === undefined) { + delete process.env.GEMINI_API_KEY; + } else { + process.env.GEMINI_API_KEY = priorGeminiKey; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("maps Gemini to the routed inference provider with supportsStore disabled", () => { assert.deepEqual(getSandboxInferenceConfig("gemini-2.5-flash", "gemini-api"), { providerKey: "inference", diff --git a/test/policies.test.js b/test/policies.test.js index 9c46509a2..77a402dc3 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -93,9 +93,9 @@ selectFromList(items, options) describe("policies", () => { describe("listPresets", () => { - it("returns all 10 presets", () => { + it("returns all 12 presets", () => { const presets = policies.listPresets(); - expect(presets.length).toBe(10); + expect(presets.length).toBe(12); }); it("each preset has name and description", () => { @@ -114,12 +114,14 @@ describe("policies", () => { "brave", "discord", "docker", + "gemini", "huggingface", "jira", "npm", "outlook", "pypi", "slack", + "tavily", "telegram", ]; expect(names).toEqual(expected); @@ -159,6 +161,15 @@ describe("policies", () => { expect(hosts).toEqual(["api.telegram.org"]); }); + it("extracts hosts from the new web search presets", () => { + expect(policies.getPresetEndpoints(policies.loadPreset("gemini"))).toEqual([ + "generativelanguage.googleapis.com", + ]); + expect(policies.getPresetEndpoints(policies.loadPreset("tavily"))).toEqual([ + "api.tavily.com", + ]); + }); + it("every preset has at least one endpoint", () => { for (const p of policies.listPresets()) { const content = policies.loadPreset(p.name); From 006c4a01296a467b8a8282a0116ee6103d070ef2 Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Sun, 5 Apr 2026 11:23:09 +0800 Subject: [PATCH 2/5] fix(onboard): address web search review follow-ups --- bin/lib/onboard.js | 21 +++- docs/reference/commands.md | 7 +- src/lib/onboard-session.test.ts | 10 +- src/lib/onboard-session.ts | 46 +++++--- src/lib/web-search.test.ts | 36 ++++++ src/lib/web-search.ts | 35 +++++- test/onboard.test.js | 202 ++++++++++++++++---------------- 7 files changed, 230 insertions(+), 127 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 7919623e9..75f13c1ea 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -985,6 +985,10 @@ function normalizeWebSearchConfigValue(config) { return webSearch.normalizeWebSearchConfig(config); } +function normalizePersistedWebSearchConfigValue(config) { + return webSearch.normalizePersistedWebSearchConfig(config); +} + function getWebSearchProviderMetadata(provider) { return webSearch.getWebSearchProvider(provider); } @@ -1103,6 +1107,10 @@ async function ensureValidatedWebSearchCredential(provider) { while (true) { if (!apiKey) { + if (isNonInteractive()) { + console.error(` ${credentialEnv} is required for ${label} in non-interactive mode.`); + return null; + } apiKey = await promptWebSearchApiKey(provider); usingSavedKey = false; } @@ -1121,6 +1129,7 @@ async function ensureValidatedWebSearchCredential(provider) { if (validation.message) { console.error(` ${validation.message}`); } + if (isNonInteractive()) return null; const action = await promptWebSearchRecovery(provider, validation); if (action === "back") return null; @@ -4192,8 +4201,16 @@ async function onboard(opts = {}) { break; } - const persistedWebSearchConfig = normalizeWebSearchConfigValue(webSearchConfig); - if (persistedWebSearchConfig) { + const persistedWebSearchConfigRaw = normalizePersistedWebSearchConfigValue(webSearchConfig); + const persistedWebSearchConfig = normalizeWebSearchConfigValue(persistedWebSearchConfigRaw); + if (persistedWebSearchConfigRaw && persistedWebSearchConfigRaw.fetchEnabled === false) { + webSearchConfig = persistedWebSearchConfigRaw; + onboardSession.updateSession((current) => { + current.webSearchConfig = webSearchConfig; + return current; + }); + note(" [resume] Keeping Web Search disabled."); + } else if (persistedWebSearchConfig) { const { label } = getWebSearchProviderMetadata(persistedWebSearchConfig.provider); note(` [resume] Revalidating ${label} configuration.`); const apiKey = await ensureValidatedWebSearchCredential(persistedWebSearchConfig.provider); diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 07a75f1dc..36b2688f5 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -71,10 +71,10 @@ Supported non-experimental choices include NVIDIA Endpoints, OpenAI, Anthropic, Credentials are stored in `~/.nemoclaw/credentials.json`. The legacy `nemoclaw setup` command is deprecated; use `nemoclaw onboard` instead. -If you enable Brave Search during onboarding, NemoClaw currently stores the Brave API key in the sandbox's OpenClaw configuration. +If you enable web search during onboarding, NemoClaw currently stores the selected provider key in the sandbox's OpenClaw configuration as that provider's `apiKey`. That means the OpenClaw agent can read the key. -NemoClaw explores an OpenShell-hosted credential path first, but the current OpenClaw Brave runtime does not consume that path end to end yet. -Treat Brave Search as an explicit opt-in and use a dedicated low-privilege Brave key. +NemoClaw explores an OpenShell-hosted credential path first, but the current OpenClaw web-search runtime does not consume that path end to end yet. +Treat web search as an explicit opt-in and use a dedicated low-privilege provider key. For non-interactive onboarding, you must explicitly accept the third-party software notice: @@ -97,6 +97,7 @@ $ BRAVE_API_KEY=... \ Supported keys are `BRAVE_API_KEY`, `GEMINI_API_KEY`, and `TAVILY_API_KEY`. If more than one is set, NemoClaw prefers Brave, then Gemini, then Tavily unless `NEMOCLAW_WEB_SEARCH_PROVIDER` is set explicitly to `brave`, `gemini`, or `tavily`. +Whichever provider wins that selection has its key copied into the sandbox OpenClaw config, so agents can read the selected provider's `apiKey`. Enabling web search also enables `web_fetch`. The wizard prompts for a sandbox name. diff --git a/src/lib/onboard-session.test.ts b/src/lib/onboard-session.test.ts index 2e215d111..fad0a42bc 100644 --- a/src/lib/onboard-session.test.ts +++ b/src/lib/onboard-session.test.ts @@ -120,7 +120,7 @@ describe("onboard session", () => { expect(loaded.metadata.token).toBeUndefined(); }); - it("normalizes legacy web search configs and clears explicit disable updates", () => { + it("normalizes legacy web search configs and preserves explicit disable updates", () => { session.saveSession(session.createSession({ webSearchConfig: { fetchEnabled: true } })); let loaded = session.loadSession(); @@ -128,7 +128,7 @@ describe("onboard session", () => { session.completeSession({ webSearchConfig: { provider: "brave", fetchEnabled: false } }); loaded = session.loadSession(); - expect(loaded.webSearchConfig).toBeNull(); + expect(loaded.webSearchConfig).toEqual({ provider: "brave", fetchEnabled: false }); }); it("does not clear existing metadata when updates omit whitelisted metadata fields", () => { @@ -204,14 +204,18 @@ describe("onboard session", () => { session.saveSession(session.createSession()); session.markStepFailed( "inference", - "provider auth failed with NVIDIA_API_KEY=nvapi-secret TAVILY_API_KEY=tvly-secret Bearer topsecret sk-secret-value ghp_1234567890123456789012345", + "provider auth failed with NVIDIA_API_KEY=nvapi-secret BRAVE_API_KEY=brv-secret GEMINI_API_KEY=gem-secret TAVILY_API_KEY=tvly-secret Bearer topsecret sk-secret-value ghp_1234567890123456789012345", ); const loaded = session.loadSession(); expect(loaded.steps.inference.error).toContain("NVIDIA_API_KEY="); + expect(loaded.steps.inference.error).toContain("BRAVE_API_KEY="); + expect(loaded.steps.inference.error).toContain("GEMINI_API_KEY="); expect(loaded.steps.inference.error).toContain("TAVILY_API_KEY="); expect(loaded.steps.inference.error).toContain("Bearer "); expect(loaded.steps.inference.error).not.toContain("nvapi-secret"); + expect(loaded.steps.inference.error).not.toContain("brv-secret"); + expect(loaded.steps.inference.error).not.toContain("gem-secret"); expect(loaded.steps.inference.error).not.toContain("tvly-secret"); expect(loaded.steps.inference.error).not.toContain("topsecret"); expect(loaded.steps.inference.error).not.toContain("sk-secret-value"); diff --git a/src/lib/onboard-session.ts b/src/lib/onboard-session.ts index b9ca5badd..b6ee86acf 100644 --- a/src/lib/onboard-session.ts +++ b/src/lib/onboard-session.ts @@ -10,7 +10,11 @@ import fs from "node:fs"; import path from "node:path"; -import { normalizeWebSearchConfig, type WebSearchConfig } from "./web-search"; +import { + getWebSearchCredentialEnvNames, + normalizePersistedWebSearchConfig, + type PersistedWebSearchConfig, +} from "./web-search"; export const SESSION_VERSION = 1; export const SESSION_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw"); @@ -55,7 +59,7 @@ export interface Session { credentialEnv: string | null; preferredInferenceApi: string | null; nimContainer: string | null; - webSearchConfig: WebSearchConfig | null; + webSearchConfig: PersistedWebSearchConfig | null; policyPresets: string[] | null; metadata: SessionMetadata; steps: Record; @@ -84,7 +88,7 @@ export interface SessionUpdates { credentialEnv?: string; preferredInferenceApi?: string; nimContainer?: string; - webSearchConfig?: WebSearchConfig | null; + webSearchConfig?: PersistedWebSearchConfig | null; policyPresets?: string[]; metadata?: { gatewayName?: string }; } @@ -119,13 +123,30 @@ export function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const REDACTED_CREDENTIAL_ENV_NAMES = Array.from( + new Set([ + "NVIDIA_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "COMPATIBLE_API_KEY", + "COMPATIBLE_ANTHROPIC_API_KEY", + ...getWebSearchCredentialEnvNames(), + ]), +); + +const CREDENTIAL_ASSIGNMENT_RE = new RegExp( + `(${REDACTED_CREDENTIAL_ENV_NAMES.map(escapeRegExp).join("|")})=\\S+`, + "gi", +); + export function redactSensitiveText(value: unknown): string | null { if (typeof value !== "string") return null; return value - .replace( - /(NVIDIA_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|COMPATIBLE_API_KEY|COMPATIBLE_ANTHROPIC_API_KEY|BRAVE_API_KEY|TAVILY_API_KEY)=\S+/gi, - "$1=", - ) + .replace(CREDENTIAL_ASSIGNMENT_RE, "$1=") .replace(/Bearer\s+\S+/gi, "Bearer ") .replace(/nvapi-[A-Za-z0-9_-]{10,}/g, "") .replace(/ghp_[A-Za-z0-9]{20,}/g, "") @@ -192,7 +213,7 @@ export function createSession(overrides: Partial = {}): Session { credentialEnv: overrides.credentialEnv || null, preferredInferenceApi: overrides.preferredInferenceApi || null, nimContainer: overrides.nimContainer || null, - webSearchConfig: normalizeWebSearchConfig(overrides.webSearchConfig), + webSearchConfig: normalizePersistedWebSearchConfig(overrides.webSearchConfig), policyPresets: Array.isArray(overrides.policyPresets) ? overrides.policyPresets.filter((value) => typeof value === "string") : null, @@ -223,7 +244,7 @@ export function normalizeSession(data: unknown): Session | null { preferredInferenceApi: typeof d.preferredInferenceApi === "string" ? d.preferredInferenceApi : null, nimContainer: typeof d.nimContainer === "string" ? d.nimContainer : null, - webSearchConfig: normalizeWebSearchConfig(d.webSearchConfig), + webSearchConfig: normalizePersistedWebSearchConfig(d.webSearchConfig), policyPresets: Array.isArray(d.policyPresets) ? (d.policyPresets as unknown[]).filter((value) => typeof value === "string") as string[] : null, @@ -410,14 +431,9 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { if (updates.webSearchConfig === null) { safe.webSearchConfig = null; } else { - const normalizedWebSearchConfig = normalizeWebSearchConfig(updates.webSearchConfig); + const normalizedWebSearchConfig = normalizePersistedWebSearchConfig(updates.webSearchConfig); if (normalizedWebSearchConfig) { safe.webSearchConfig = normalizedWebSearchConfig; - } else if ( - isObject(updates.webSearchConfig) && - updates.webSearchConfig.fetchEnabled === false - ) { - safe.webSearchConfig = null; } } } diff --git a/src/lib/web-search.test.ts b/src/lib/web-search.test.ts index bb8adeda2..423850afb 100644 --- a/src/lib/web-search.test.ts +++ b/src/lib/web-search.test.ts @@ -33,6 +33,10 @@ describe("web-search helpers", () => { }); }); + it("rejects persisted configs with unsupported providers", () => { + expect(normalizeWebSearchConfig({ provider: "duckduckgo", fetchEnabled: true })).toBeNull(); + }); + it("builds the Brave Search OpenClaw config fragment", () => { expect( buildWebSearchConfigFragment({ provider: "brave", fetchEnabled: true }, "brv-x"), @@ -92,6 +96,38 @@ describe("web-search helpers", () => { }); }); + it("encodes Tavily Search docker config using the Tavily plugin entry", () => { + const encoded = buildWebSearchDockerConfig( + { provider: "tavily", fetchEnabled: true }, + "tvly-x", + ); + expect(JSON.parse(Buffer.from(encoded, "base64").toString("utf8"))).toEqual({ + plugins: { + entries: { + tavily: { + enabled: true, + config: { + webSearch: { + apiKey: "tvly-x", + }, + }, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: "tavily", + }, + fetch: { + enabled: true, + }, + }, + }, + }); + }); + it("includes provider-specific exposure caveats in the warning text", () => { const warning = getWebSearchExposureWarningLines("tavily").join(" "); expect(warning).toContain("Tavily API key"); diff --git a/src/lib/web-search.ts b/src/lib/web-search.ts index d1fb2918f..4f4a2f236 100644 --- a/src/lib/web-search.ts +++ b/src/lib/web-search.ts @@ -8,6 +8,13 @@ export interface WebSearchConfig { fetchEnabled: boolean; } +export interface DisabledWebSearchConfig { + provider?: WebSearchProvider; + fetchEnabled: false; +} + +export type PersistedWebSearchConfig = WebSearchConfig | DisabledWebSearchConfig; + export interface WebSearchProviderMetadata { provider: WebSearchProvider; label: string; @@ -74,14 +81,36 @@ export function getWebSearchProvider(provider: WebSearchProvider): WebSearchProv return WEB_SEARCH_PROVIDERS[provider]; } -export function normalizeWebSearchConfig(value: unknown): WebSearchConfig | null { - if (!isObject(value) || value.fetchEnabled !== true) return null; +export function normalizePersistedWebSearchConfig( + value: unknown, +): PersistedWebSearchConfig | null { + if (!isObject(value) || typeof value.fetchEnabled !== "boolean") return null; + + if (value.fetchEnabled === false) { + const provider = + value.provider === undefined ? undefined : parseWebSearchProvider(value.provider); + if (value.provider !== undefined && !provider) return null; + return provider ? { provider, fetchEnabled: false } : { fetchEnabled: false }; + } + + const provider = + value.provider === undefined ? "brave" : parseWebSearchProvider(value.provider); + if (!provider) return null; return { - provider: parseWebSearchProvider(value.provider) || "brave", + provider, fetchEnabled: true, }; } +export function normalizeWebSearchConfig(value: unknown): WebSearchConfig | null { + const normalized = normalizePersistedWebSearchConfig(value); + return normalized?.fetchEnabled === true ? normalized : null; +} + +export function getWebSearchCredentialEnvNames(): string[] { + return listWebSearchProviders().map((provider) => provider.credentialEnv); +} + export function getWebSearchExposureWarningLines(provider: WebSearchProvider): string[] { const { label } = getWebSearchProvider(provider); return [ diff --git a/test/onboard.test.js b/test/onboard.test.js index 3e8d320e3..d740c5950 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -35,6 +35,26 @@ import { } from "../bin/lib/onboard"; import { buildWebSearchDockerConfig } from "../dist/lib/web-search"; +const WEB_SEARCH_DOCKERFILE_TEMPLATE = [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "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=", + "ARG NEMOCLAW_BUILD_ID=default", +]; + +function writeWebSearchDockerfileFixture(dockerfilePath) { + fs.writeFileSync(dockerfilePath, WEB_SEARCH_DOCKERFILE_TEMPLATE.join("\n")); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + describe("onboard helpers", () => { it("classifies sandbox create timeout failures and tracks upload progress", () => { expect( @@ -222,108 +242,58 @@ describe("onboard helpers", () => { } }); - it("patches the staged Dockerfile with Brave Search config when enabled", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-web-")); - const dockerfilePath = path.join(tmpDir, "Dockerfile"); - fs.writeFileSync( - dockerfilePath, - [ - "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", - "ARG NEMOCLAW_PROVIDER_KEY=nvidia", - "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", - "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=", - "ARG NEMOCLAW_BUILD_ID=default", - ].join("\n"), - ); - - const priorBraveKey = process.env.BRAVE_API_KEY; - process.env.BRAVE_API_KEY = "brv-test-key"; - try { - patchStagedDockerfile( - dockerfilePath, - "gpt-5.4", - "http://127.0.0.1:18789", - "build-web", - "openai-api", - null, - { provider: "brave", fetchEnabled: true }, - ); - const patched = fs.readFileSync(dockerfilePath, "utf8"); - const expected = buildWebSearchDockerConfig( - { provider: "brave", fetchEnabled: true }, - "brv-test-key", - ); - assert.match( - patched, - new RegExp( - `^ARG NEMOCLAW_WEB_CONFIG_B64=${expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, - "m", - ), - ); - } finally { - if (priorBraveKey === undefined) { - delete process.env.BRAVE_API_KEY; - } else { - process.env.BRAVE_API_KEY = priorBraveKey; - } - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("patches the staged Dockerfile with Gemini Search config when enabled", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-web-")); - const dockerfilePath = path.join(tmpDir, "Dockerfile"); - fs.writeFileSync( - dockerfilePath, - [ - "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", - "ARG NEMOCLAW_PROVIDER_KEY=nvidia", - "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", - "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=", - "ARG NEMOCLAW_BUILD_ID=default", - ].join("\n"), - ); - - const priorGeminiKey = process.env.GEMINI_API_KEY; - process.env.GEMINI_API_KEY = "gem-test-key"; - try { - patchStagedDockerfile( - dockerfilePath, - "gpt-5.4", - "http://127.0.0.1:18789", - "build-web", - "openai-api", - null, - { provider: "gemini", fetchEnabled: true }, - ); - const patched = fs.readFileSync(dockerfilePath, "utf8"); - const expected = buildWebSearchDockerConfig( - { provider: "gemini", fetchEnabled: true }, - "gem-test-key", - ); - assert.match( - patched, - new RegExp( - `^ARG NEMOCLAW_WEB_CONFIG_B64=${expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, - "m", - ), - ); - } finally { - if (priorGeminiKey === undefined) { - delete process.env.GEMINI_API_KEY; - } else { - process.env.GEMINI_API_KEY = priorGeminiKey; + [ + { + provider: "brave", + credentialEnv: "BRAVE_API_KEY", + apiKey: "brv-test-key", + label: "Brave Search", + }, + { + provider: "gemini", + credentialEnv: "GEMINI_API_KEY", + apiKey: "gem-test-key", + label: "Gemini Search", + }, + { + provider: "tavily", + credentialEnv: "TAVILY_API_KEY", + apiKey: "tav-test-key", + label: "Tavily Search", + }, + ].forEach(({ provider, credentialEnv, apiKey, label }) => { + it(`patches the staged Dockerfile with ${label} config when enabled`, () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-web-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + writeWebSearchDockerfileFixture(dockerfilePath); + + const priorApiKey = process.env[credentialEnv]; + process.env[credentialEnv] = apiKey; + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + "build-web", + "openai-api", + null, + { provider, fetchEnabled: true }, + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + const expected = buildWebSearchDockerConfig({ provider, fetchEnabled: true }, apiKey); + assert.match( + patched, + new RegExp(`^ARG NEMOCLAW_WEB_CONFIG_B64=${escapeRegExp(expected)}$`, "m"), + ); + } finally { + if (priorApiKey === undefined) { + delete process.env[credentialEnv]; + } else { + process.env[credentialEnv] = priorApiKey; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); } - fs.rmSync(tmpDir, { recursive: true, force: true }); - } + }); }); it("maps Gemini to the routed inference provider with supportsStore disabled", () => { @@ -1203,6 +1173,36 @@ const { setupInference } = require(${onboardPath}); ); }); + it("fails fast instead of prompting during non-interactive web-search credential revalidation", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + + assert.match( + source, + /if \(!apiKey\) \{\s*if \(isNonInteractive\(\)\) \{\s*console\.error\(` \$\{credentialEnv\} is required for \$\{label\} in non-interactive mode\.`\);\s*return null;\s*\}\s*apiKey = await promptWebSearchApiKey\(provider\);/s, + ); + assert.match(source, /if \(isNonInteractive\(\)\) return null;/); + }); + + it("preserves an explicitly disabled web-search choice during resume", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + + assert.match( + source, + /const persistedWebSearchConfigRaw = normalizePersistedWebSearchConfigValue\(webSearchConfig\);/, + ); + assert.match( + source, + /if \(persistedWebSearchConfigRaw && persistedWebSearchConfigRaw\.fetchEnabled === false\) \{/, + ); + assert.match(source, /note\(" \[resume\] Keeping Web Search disabled\."\);/); + }); + it("prints numbered step headers even when onboarding skips resumed steps", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), From dee0220700f6f8b650ffa60994e0acce5616cb48 Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Sun, 5 Apr 2026 11:36:39 +0800 Subject: [PATCH 3/5] fix(web-search): include default Gemini model --- src/lib/web-search.test.ts | 2 ++ src/lib/web-search.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/lib/web-search.test.ts b/src/lib/web-search.test.ts index 423850afb..db3c83edd 100644 --- a/src/lib/web-search.test.ts +++ b/src/lib/web-search.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { buildWebSearchConfigFragment, buildWebSearchDockerConfig, + DEFAULT_GEMINI_WEB_SEARCH_MODEL, getWebSearchExposureWarningLines, normalizeWebSearchConfig, } from "./web-search"; @@ -76,6 +77,7 @@ describe("web-search helpers", () => { enabled: true, config: { webSearch: { + model: DEFAULT_GEMINI_WEB_SEARCH_MODEL, apiKey: "g-x", }, }, diff --git a/src/lib/web-search.ts b/src/lib/web-search.ts index 4f4a2f236..08ded129c 100644 --- a/src/lib/web-search.ts +++ b/src/lib/web-search.ts @@ -134,6 +134,9 @@ export function buildWebSearchConfigFragment( enabled: true, config: { webSearch: { + ...(normalized.provider === "gemini" + ? { model: DEFAULT_GEMINI_WEB_SEARCH_MODEL } + : {}), ...(apiKey ? { apiKey } : {}), }, }, From 01747519f27340092d042a3cb19963e46f813905 Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Sun, 5 Apr 2026 17:06:05 +0800 Subject: [PATCH 4/5] fix(sandbox): make OpenClaw config writable after onboarding --- Dockerfile | 51 +++++++------ Dockerfile.base | 9 ++- bin/lib/onboard.js | 9 ++- docs/reference/commands.md | 1 + docs/security/best-practices.md | 19 ++--- .../policies/openclaw-sandbox.yaml | 10 +-- scripts/nemoclaw-start.sh | 68 +++++++++++------ test/e2e-gateway-isolation.sh | 73 ++++++++++++------- test/nemoclaw-start.test.js | 34 +++++++-- test/openclaw-config-layout.test.js | 38 ++++++++++ 10 files changed, 211 insertions(+), 101 deletions(-) create mode 100644 test/openclaw-config-layout.test.js diff --git a/Dockerfile b/Dockerfile index c9f233c5a..33bf2b37d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,14 +73,17 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} \ NEMOCLAW_WEB_CONFIG_B64=${NEMOCLAW_WEB_CONFIG_B64} \ - NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} + NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ + OPENCLAW_STATE_DIR=/sandbox/.openclaw \ + OPENCLAW_CONFIG_PATH=/sandbox/.openclaw-data/config/openclaw.json WORKDIR /sandbox USER sandbox # Write the COMPLETE openclaw.json including gateway config and auth token. -# This file is immutable at runtime (Landlock read-only on /sandbox/.openclaw). -# No runtime writes to openclaw.json are needed or possible. +# The live config lives under /sandbox/.openclaw-data/config so OpenClaw CLI +# and Control UI edits can persist after onboarding. /sandbox/.openclaw stays +# as the immutable wrapper path and exposes the live config through a symlink. # Build args (NEMOCLAW_MODEL, CHAT_UI_URL) customize per deployment. # Auth token is generated per build so each image has a unique token. RUN python3 -c "\ @@ -124,37 +127,39 @@ config = { \ } \ }; \ config.update(web_config if isinstance(web_config, dict) else {}); \ -path = os.path.expanduser('~/.openclaw/openclaw.json'); \ -json.dump(config, open(path, 'w'), indent=2); \ -os.chmod(path, 0o600)" +state_dir = os.environ.get('OPENCLAW_STATE_DIR', os.path.expanduser('~/.openclaw')); \ +config_path = os.environ.get('OPENCLAW_CONFIG_PATH', os.path.join(state_dir, 'openclaw.json')); \ +wrapper_path = os.path.join(state_dir, 'openclaw.json'); \ +os.makedirs(os.path.dirname(config_path), exist_ok=True); \ +os.makedirs(state_dir, exist_ok=True); \ +if os.path.lexists(wrapper_path) and not os.path.islink(wrapper_path): \ + os.remove(wrapper_path); \ +if os.path.islink(wrapper_path) and os.path.realpath(wrapper_path) != config_path: \ + os.remove(wrapper_path); \ +if not os.path.islink(wrapper_path): \ + os.symlink(config_path, wrapper_path); \ +with open(config_path, 'w', encoding='utf-8') as fh: \ + json.dump(config, fh, indent=2); \ + fh.write('\n'); \ +os.chmod(config_path, 0o660)" # Install NemoClaw plugin into OpenClaw RUN openclaw doctor --fix > /dev/null 2>&1 || true \ && openclaw plugins install /opt/nemoclaw > /dev/null 2>&1 || true -# Lock openclaw.json via DAC: chown to root so the sandbox user cannot modify -# it at runtime. This works regardless of Landlock enforcement status. -# The Landlock policy (/sandbox/.openclaw in read_only) provides defense-in-depth -# once OpenShell enables enforcement. +# Lock the .openclaw wrapper tree via DAC. The wrapper path stays read-only +# and root-owned so the sandbox user cannot replace symlinks or swap the live +# config path. The active config file itself lives in .openclaw-data/config and +# is shared between the sandbox user and the gateway process. # Ref: https://github.com/NVIDIA/NemoClaw/issues/514 -# Lock the entire .openclaw directory tree. -# SECURITY: chmod 755 (not 1777) — the sandbox user can READ but not WRITE -# to this directory. This prevents the agent from replacing symlinks -# (e.g., pointing /sandbox/.openclaw/hooks to an attacker-controlled path). -# The writable state lives in .openclaw-data, reached via the symlinks. # hadolint ignore=DL3002 USER root RUN chown root:root /sandbox/.openclaw \ && find /sandbox/.openclaw -mindepth 1 -maxdepth 1 -exec chown -h root:root {} + \ && chmod 755 /sandbox/.openclaw \ - && chmod 444 /sandbox/.openclaw/openclaw.json - -# Pin config hash at build time so the entrypoint can verify integrity. -# Prevents the agent from creating a copy with a tampered config and -# restarting the gateway pointing at it. -RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash \ - && chmod 444 /sandbox/.openclaw/.config-hash \ - && chown root:root /sandbox/.openclaw/.config-hash + && chown sandbox:gateway /sandbox/.openclaw-data/config /sandbox/.openclaw-data/config/openclaw.json \ + && chmod 2775 /sandbox/.openclaw-data/config \ + && chmod 664 /sandbox/.openclaw-data/config/openclaw.json # Entrypoint runs as root to start the gateway as the gateway user, # then drops to sandbox for agent commands. See nemoclaw-start.sh. diff --git a/Dockerfile.base b/Dockerfile.base index 3fd658485..89f64fd06 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -87,12 +87,14 @@ RUN groupadd -r gateway && useradd -r -g gateway -d /sandbox -s /usr/sbin/nologi && mkdir -p /sandbox/.nemoclaw \ && chown -R sandbox:sandbox /sandbox -# Split .openclaw into immutable config dir + writable state dir. +# Split .openclaw into an immutable wrapper + writable state dir. # The policy makes /sandbox/.openclaw read-only via Landlock, so the agent -# cannot modify openclaw.json, auth tokens, or CORS settings. Writable -# state (agents, plugins, etc.) lives in .openclaw-data, reached via symlinks. +# cannot replace symlinks or rewrite the wrapper layout. Writable state, +# including the active openclaw.json, lives in .openclaw-data and is exposed +# through fixed symlinks. # Ref: https://github.com/NVIDIA/NemoClaw/issues/514 RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ + /sandbox/.openclaw-data/config \ /sandbox/.openclaw-data/extensions \ /sandbox/.openclaw-data/workspace \ /sandbox/.openclaw-data/skills \ @@ -103,6 +105,7 @@ RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ /sandbox/.openclaw-data/cron \ /sandbox/.openclaw-data/memory \ && mkdir -p /sandbox/.openclaw \ + && ln -s /sandbox/.openclaw-data/config/openclaw.json /sandbox/.openclaw/openclaw.json \ && ln -s /sandbox/.openclaw-data/agents /sandbox/.openclaw/agents \ && ln -s /sandbox/.openclaw-data/extensions /sandbox/.openclaw/extensions \ && ln -s /sandbox/.openclaw-data/workspace /sandbox/.openclaw/workspace \ diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 75f13c1ea..fef57d4b2 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -945,10 +945,11 @@ function pruneStaleSandboxEntry(sandboxName) { } function buildSandboxConfigSyncScript(selectionConfig) { - // openclaw.json is immutable (root:root 444, Landlock read-only) — never - // write to it at runtime. Model routing is handled by the host-side - // gateway (`openshell inference set` in Step 5), not from inside the - // sandbox. We only write the NemoClaw selection config (~/.nemoclaw/). + // The live OpenClaw config now remains writable after onboarding so users + // can update dashboard, channel, and gateway settings from inside the + // sandbox. Model routing is still handled by the host-side gateway + // (`openshell inference set` in Step 5), so this sync script only writes + // NemoClaw's own selection config (~/.nemoclaw/). return ` set -euo pipefail mkdir -p ~/.nemoclaw diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 36b2688f5..c9223fc6c 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -69,6 +69,7 @@ $ nemoclaw onboard The wizard prompts for a provider first, then collects the provider credential if needed. Supported non-experimental choices include NVIDIA Endpoints, OpenAI, Anthropic, Google Gemini, and compatible OpenAI or Anthropic endpoints. Credentials are stored in `~/.nemoclaw/credentials.json`. +The sandbox's live OpenClaw config is stored at `/sandbox/.openclaw-data/config/openclaw.json` and exposed at `/sandbox/.openclaw/openclaw.json`, so later `openclaw config set` and dashboard config edits persist after onboarding. The legacy `nemoclaw setup` command is deprecated; use `nemoclaw onboard` instead. If you enable web search during onboarding, NemoClaw currently stores the selected provider key in the sandbox's OpenClaw configuration as that provider's `apiKey`. diff --git a/docs/security/best-practices.md b/docs/security/best-practices.md index 6bc188659..d08118bda 100644 --- a/docs/security/best-practices.md +++ b/docs/security/best-practices.md @@ -225,23 +225,24 @@ The container mounts system directories read-only to prevent the agent from modi | Risk if relaxed | Making `/usr` or `/lib` writable lets the agent replace system binaries (such as `curl` or `node`) with trojanized versions. Making `/etc` writable lets the agent modify DNS resolution, TLS trust stores, or user accounts. | | Recommendation | Never make system paths writable. If the agent needs a writable location for generated files, use a subdirectory of `/sandbox`. | -### Read-Only `.openclaw` Config +### Hardened `.openclaw` Wrapper The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration, including auth tokens and CORS settings. -The container mounts it read-only while writable agent state (plugins, agent data) lives in `/sandbox/.openclaw-data` through symlinks. +The container mounts the wrapper path read-only while writable agent state and the live config file live in `/sandbox/.openclaw-data`. +`/sandbox/.openclaw/openclaw.json` is a fixed symlink to `/sandbox/.openclaw-data/config/openclaw.json`, so legit config updates still work without reopening the wrapper directory itself. Multiple defense layers protect this directory: -- **DAC permissions.** Root owns the directory and `openclaw.json` with `chmod 444`, so the sandbox user cannot write to them. -- **Immutable flag.** The entrypoint applies `chattr +i` to the directory and all symlinks, preventing modification even if other controls fail. -- **Symlink validation.** At startup, the entrypoint verifies every symlink in `.openclaw` points to the expected `.openclaw-data` target. If any symlink points elsewhere, the container refuses to start. -- **Config integrity hash.** The build process pins a SHA256 hash of `openclaw.json`. The entrypoint verifies it at startup and refuses to start if the hash does not match. +- **DAC permissions.** Root owns the wrapper directory and the symlink entries under it, so the sandbox user cannot replace them. +- **Immutable flag.** The entrypoint applies `chattr +i` to the directory and all wrapper symlinks, preventing modification even if other controls fail. +- **Symlink validation.** At startup, the entrypoint verifies every symlink in `.openclaw` points to the expected target before the gateway launches. +- **Shared live-config path.** The live config file lives in `/sandbox/.openclaw-data/config/openclaw.json`, which is writable by both the sandbox user and the gateway process so `openclaw config`, Control UI edits, and direct file updates persist cleanly. | Aspect | Detail | |---|---| -| Default | The container mounts `/sandbox/.openclaw` as read-only, root-owned, immutable, and integrity-verified at startup. `/sandbox/.openclaw-data` remains writable. | +| Default | The container mounts `/sandbox/.openclaw` as a read-only, root-owned wrapper with immutable symlinks. The live config file lives in `/sandbox/.openclaw-data/config/openclaw.json`, exposed at `/sandbox/.openclaw/openclaw.json`, and `/sandbox/.openclaw-data` remains writable. | | What you can change | Move `/sandbox/.openclaw` from `read_only` to `read_write` in the policy file. | -| Risk if relaxed | A writable `.openclaw` directory lets the agent modify its own gateway config: disabling CORS, changing auth tokens, or redirecting inference to an attacker-controlled endpoint. This is the single most dangerous filesystem change. | +| Risk if relaxed | A writable `.openclaw` directory lets the agent replace wrapper symlinks and redirect config-backed paths to attacker-controlled locations. This is the single most dangerous filesystem change. | | Recommendation | Never make `/sandbox/.openclaw` writable. | ### Writable Paths @@ -372,7 +373,7 @@ Device authentication requires each connecting device to go through a pairing fl | Aspect | Detail | |---|---| | Default | Enabled. The gateway requires device pairing for all connections. | -| What you can change | Set `NEMOCLAW_DISABLE_DEVICE_AUTH=1` as a Docker build argument to disable device authentication. This is a build-time setting baked into `openclaw.json` and verified by hash at startup. | +| What you can change | Set `NEMOCLAW_DISABLE_DEVICE_AUTH=1` as a Docker build argument to change the initial value, or edit `gateway.controlUi.dangerouslyDisableDeviceAuth` in the sandbox's OpenClaw config afterward. | | Risk if relaxed | Disabling device auth allows any device on the network to connect to the gateway without proving identity. This is dangerous when combined with LAN-bind changes or cloudflared tunnels in remote deployments, resulting in an unauthenticated, publicly reachable dashboard. | | Recommendation | Keep device auth enabled (the default). Only disable it for headless or development environments where no untrusted devices can reach the gateway. | diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index b29eaa2dd..4e203855e 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -25,16 +25,16 @@ filesystem_policy: - /app - /etc - /var/log - - /sandbox/.openclaw # Immutable gateway config — prevents agent - # from tampering with auth tokens or CORS. - # Writable state (agents, plugins) lives in - # /sandbox/.openclaw-data via symlinks. + - /sandbox/.openclaw # Immutable wrapper path — prevents agent + # from replacing symlinks or swapping the + # active config path. Live config and other + # writable state live in /sandbox/.openclaw-data. # Ref: https://github.com/NVIDIA/NemoClaw/issues/514 read_write: - /sandbox - /tmp - /dev/null - - /sandbox/.openclaw-data # Writable agent/plugin state (symlinked from .openclaw) + - /sandbox/.openclaw-data # Writable state, including the active OpenClaw config landlock: compatibility: best_effort diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index f80c4a272..8f4ae6c52 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -6,15 +6,18 @@ # gateway as the 'gateway' user, then drops to 'sandbox' for agent commands. # # SECURITY: The gateway runs as a separate user so the sandboxed agent cannot -# kill it or restart it with a tampered config (CVE: fake-HOME bypass). -# The config hash is verified at startup to detect tampering. +# kill it or restart it with a tampered HOME or a rewritten .openclaw wrapper +# (CVE: fake-HOME bypass). The wrapper layout and config symlink are verified +# at startup before the gateway launches. # # Optional env: # NVIDIA_API_KEY API key for NVIDIA-hosted inference # CHAT_UI_URL Browser origin that will access the forwarded dashboard +# OPENCLAW_STATE_DIR Active OpenClaw state dir (default: /sandbox/.openclaw) +# OPENCLAW_CONFIG_PATH Active OpenClaw config path # NEMOCLAW_DISABLE_DEVICE_AUTH Build-time only. Set to "1" to skip device-pairing auth -# (development/headless). Has no runtime effect — openclaw.json -# is baked at image build and verified by hash at startup. +# (development/headless). Runtime changes belong in the +# OpenClaw config, not this env var. set -euo pipefail @@ -96,21 +99,34 @@ NEMOCLAW_CMD=("$@") CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:18789}" PUBLIC_PORT=18789 OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere - -# ── Config integrity check ────────────────────────────────────── -# The config hash was pinned at build time. If it doesn't match, -# someone (or something) has tampered with the config. - -verify_config_integrity() { - local hash_file="/sandbox/.openclaw/.config-hash" - if [ ! -f "$hash_file" ]; then - echo "[SECURITY] Config hash file missing — refusing to start without integrity verification" >&2 +OPENCLAW_STATE_DIR="${OPENCLAW_STATE_DIR:-/sandbox/.openclaw}" +OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-/sandbox/.openclaw-data/config/openclaw.json}" +OPENCLAW_CONFIG_SYMLINK="${OPENCLAW_STATE_DIR}/openclaw.json" +export OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH + +# ── OpenClaw layout verification ──────────────────────────────── +# The .openclaw wrapper stays read-only and symlink-validated. The live +# config file is writable and lives outside the wrapper at +# ${OPENCLAW_CONFIG_PATH}. + +verify_config_layout() { + local config_dir target + config_dir="$(dirname "$OPENCLAW_CONFIG_PATH")" + if [ ! -d "$config_dir" ]; then + echo "[SECURITY] OpenClaw config directory missing: ${config_dir}" >&2 + return 1 + fi + if [ ! -L "$OPENCLAW_CONFIG_SYMLINK" ]; then + echo "[SECURITY] OpenClaw config wrapper is not a symlink: ${OPENCLAW_CONFIG_SYMLINK}" >&2 + return 1 + fi + target="$(readlink -f "$OPENCLAW_CONFIG_SYMLINK" 2>/dev/null || true)" + if [ "$target" != "$OPENCLAW_CONFIG_PATH" ]; then + echo "[SECURITY] OpenClaw config symlink target mismatch: ${target} (expected ${OPENCLAW_CONFIG_PATH})" >&2 return 1 fi - if ! (cd /sandbox/.openclaw && sha256sum -c "$hash_file" --status 2>/dev/null); then - echo "[SECURITY] openclaw.json integrity check FAILED — config may have been tampered with" >&2 - echo "[SECURITY] Expected hash: $(cat "$hash_file")" >&2 - echo "[SECURITY] Actual hash: $(sha256sum /sandbox/.openclaw/openclaw.json)" >&2 + if [ ! -f "$OPENCLAW_CONFIG_PATH" ]; then + echo "[SECURITY] OpenClaw config file missing: ${OPENCLAW_CONFIG_PATH}" >&2 return 1 fi } @@ -123,7 +139,8 @@ write_auth_profile() { python3 - <<'PYAUTH' import json import os -path = os.path.expanduser('~/.openclaw/agents/main/agent/auth-profiles.json') +state_dir = os.environ.get('OPENCLAW_STATE_DIR', os.path.expanduser('~/.openclaw')) +path = os.path.join(state_dir, 'agents', 'main', 'agent', 'auth-profiles.json') os.makedirs(os.path.dirname(path), exist_ok=True) json.dump({ 'nvidia:manual': { @@ -144,7 +161,7 @@ print_dashboard_urls() { python3 - <<'PYTOKEN' import json import os -path = '/sandbox/.openclaw/openclaw.json' +path = os.environ.get('OPENCLAW_CONFIG_PATH', '/sandbox/.openclaw/openclaw.json') try: cfg = json.load(open(path)) except Exception: @@ -341,8 +358,8 @@ echo 'Setting up NemoClaw...' >&2 if [ "$(id -u)" -ne 0 ]; then echo "[gateway] Running as non-root (uid=$(id -u)) — privilege separation disabled" >&2 export HOME=/sandbox - if ! verify_config_integrity; then - echo "[SECURITY] Config integrity check failed — refusing to start (non-root mode)" >&2 + if ! verify_config_layout; then + echo "[SECURITY] OpenClaw config layout check failed — refusing to start (non-root mode)" >&2 exit 1 fi write_auth_profile @@ -372,8 +389,8 @@ fi # ── Root path (full privilege separation via gosu) ───────────── -# Verify config integrity before starting anything -verify_config_integrity +# Verify the OpenClaw config path wiring before starting anything +verify_config_layout # Write auth profile as sandbox user (needs writable .openclaw-data) gosu sandbox bash -c "$(declare -f write_auth_profile); write_auth_profile" @@ -399,7 +416,10 @@ for entry in /sandbox/.openclaw/*; do [ -L "$entry" ] || continue name="$(basename "$entry")" target="$(readlink -f "$entry" 2>/dev/null || true)" - expected="/sandbox/.openclaw-data/$name" + case "$name" in + openclaw.json) expected="$OPENCLAW_CONFIG_PATH" ;; + *) expected="/sandbox/.openclaw-data/$name" ;; + esac if [ "$target" != "$expected" ]; then echo "[SECURITY] Symlink $entry points to unexpected target: $target (expected $expected)" >&2 exit 1 diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index cb4c8d698..a4ec30030 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -69,19 +69,37 @@ else fail "gateway and sandbox UIDs not distinct: $OUT" fi -# ── Test 2: openclaw.json is not writable by sandbox user ──────── +# ── Test 2: openclaw.json resolves to the live writable config ─── -info "2. openclaw.json is not writable by sandbox user" -OUT=$(run_as_sandbox "touch /sandbox/.openclaw/openclaw.json 2>&1 || echo BLOCKED") -if echo "$OUT" | grep -q "BLOCKED\|Permission denied\|Read-only"; then - pass "sandbox cannot write to openclaw.json" +info "2. openclaw.json wrapper symlink points to the live config" +OUT=$(run_as_root "readlink -f /sandbox/.openclaw/openclaw.json") +if [ "$OUT" = "/sandbox/.openclaw-data/config/openclaw.json" ]; then + pass "openclaw.json wrapper points to live config" else - fail "sandbox CAN write to openclaw.json: $OUT" + fail "openclaw.json wrapper points to unexpected target: $OUT" fi -# ── Test 3: .openclaw directory is not writable by sandbox ─────── +# ── Test 3: sandbox user can update the live config ────────────── + +info "3. sandbox user can write the live OpenClaw config" +OUT=$(run_as_sandbox "python3 - <<'PY' +import json +from pathlib import Path +path = Path('/sandbox/.openclaw/openclaw.json') +cfg = json.loads(path.read_text()) +cfg.setdefault('gateway', {}).setdefault('controlUi', {})['allowedOrigins'] = ['https://sandbox-write.test'] +path.write_text(json.dumps(cfg)) +print('WRITABLE') +PY") +if echo "$OUT" | grep -q "WRITABLE"; then + pass "sandbox can update the live config" +else + fail "sandbox could not update the live config: $OUT" +fi + +# ── Test 4: .openclaw directory is not writable by sandbox ─────── -info "3. .openclaw directory not writable by sandbox (no symlink replacement)" +info "4. .openclaw directory not writable by sandbox (no symlink replacement)" # ln -sf may return 0 even when it fails to replace (silent failure on perm denied). # Verify the symlink still points to the expected target after the attempt. OUT=$(run_as_sandbox "ln -sf /tmp/evil /sandbox/.openclaw/hooks 2>&1; readlink /sandbox/.openclaw/hooks") @@ -92,24 +110,22 @@ else fail "sandbox replaced symlink — hooks now points to: $TARGET" fi -# ── Test 4: Config hash file exists and is valid ───────────────── - -info "4. Config hash exists and matches openclaw.json" -OUT=$(run_as_root "cd /sandbox/.openclaw && sha256sum -c .config-hash --status && echo VALID || echo INVALID") -if echo "$OUT" | grep -q "VALID"; then - pass "config hash matches openclaw.json" +# ── Test 5: gateway user can write the live config as well ─────── + +info "5. gateway user can write the live config" +OUT=$(run_as_root "gosu gateway python3 - <<'PY' +import json +from pathlib import Path +path = Path('/sandbox/.openclaw-data/config/openclaw.json') +cfg = json.loads(path.read_text()) +cfg.setdefault('gateway', {}).setdefault('controlUi', {})['allowedOrigins'] = ['https://gateway-write.test'] +path.write_text(json.dumps(cfg)) +print('WRITABLE') +PY") +if echo "$OUT" | grep -q "WRITABLE"; then + pass "gateway can update the live config" else - fail "config hash mismatch: $OUT" -fi - -# ── Test 5: Config hash is not writable by sandbox ─────────────── - -info "5. Config hash not writable by sandbox user" -OUT=$(run_as_sandbox "echo fake > /sandbox/.openclaw/.config-hash 2>&1 || echo BLOCKED") -if echo "$OUT" | grep -q "BLOCKED\|Permission denied"; then - pass "sandbox cannot tamper with config hash" -else - fail "sandbox CAN write to config hash: $OUT" + fail "gateway could not update the live config: $OUT" fi # ── Test 6: gosu is installed ──────────────────────────────────── @@ -159,6 +175,13 @@ else fail "symlink targets wrong:$FAILED_LINKS" fi +OUT=$(run_as_root "readlink -f /sandbox/.openclaw/openclaw.json") +if [ "$OUT" = "/sandbox/.openclaw-data/config/openclaw.json" ]; then + pass "openclaw.json symlink points to the writable config target" +else + fail "openclaw.json symlink target wrong: $OUT" +fi + # ── Test 10: iptables is installed (required for network policy enforcement) ── info "10. iptables is installed" diff --git a/test/nemoclaw-start.test.js b/test/nemoclaw-start.test.js index c51d26eaf..a0ac34a1c 100644 --- a/test/nemoclaw-start.test.js +++ b/test/nemoclaw-start.test.js @@ -16,24 +16,40 @@ describe("nemoclaw-start non-root fallback", () => { expect(src).toMatch(/nohup "\$OPENCLAW" gateway run >\/tmp\/gateway\.log 2>&1 &/); }); - it("exits on config integrity failure in non-root mode", () => { + it("exits on config layout failure in non-root mode", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - // Non-root block must call verify_config_integrity and exit 1 on failure - expect(src).toMatch(/if ! verify_config_integrity; then\s+.*exit 1/s); + // Non-root block must call verify_config_layout and exit 1 on failure + expect(src).toMatch(/if ! verify_config_layout; then\s+.*exit 1/s); // Must not contain the old "proceeding anyway" fallback expect(src).not.toMatch(/proceeding anyway/i); }); - it("calls verify_config_integrity in both root and non-root paths", () => { + it("calls verify_config_layout in both root and non-root paths", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); // The function must be called at least twice: once in the non-root // if-block and once in the root path below it. - const calls = src.match(/verify_config_integrity/g) || []; + const calls = src.match(/verify_config_layout/g) || []; expect(calls.length).toBeGreaterThanOrEqual(3); // definition + 2 call sites }); + it("exports the live OpenClaw config path under .openclaw-data", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + expect(src).toContain('OPENCLAW_STATE_DIR="${OPENCLAW_STATE_DIR:-/sandbox/.openclaw}"'); + expect(src).toContain( + 'OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-/sandbox/.openclaw-data/config/openclaw.json}"', + ); + expect(src).toContain('OPENCLAW_CONFIG_SYMLINK="${OPENCLAW_STATE_DIR}/openclaw.json"'); + }); + + it("reads dashboard tokens from OPENCLAW_CONFIG_PATH", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + expect(src).toContain("os.environ.get('OPENCLAW_CONFIG_PATH'"); + }); + it("sends startup diagnostics to stderr so they do not leak into bridge output (#1064)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); @@ -105,11 +121,13 @@ describe("nemoclaw-start auto-pair client whitelisting (#117)", () => { }); it("documents NEMOCLAW_DISABLE_DEVICE_AUTH as a build-time setting in the script header", () => { - // Must mention it's build-time only — setting at runtime has no effect - // because openclaw.json is baked and immutable + // Must mention it's still a build-time default and point runtime changes + // at the OpenClaw config instead of this env var. const header = src.split("set -euo pipefail")[0]; expect(header).toMatch(/NEMOCLAW_DISABLE_DEVICE_AUTH/); - expect(header).toMatch(/build[- ]time/i); + expect(header).toMatch(/Build-time/i); + expect(header).toMatch(/Runtime changes belong in the/i); + expect(header).toMatch(/OpenClaw config, not this env var/i); }); it("defines ALLOWED_CLIENTS and ALLOWED_MODES outside the poll loop", () => { diff --git a/test/openclaw-config-layout.test.js b/test/openclaw-config-layout.test.js new file mode 100644 index 000000000..143a600a7 --- /dev/null +++ b/test/openclaw-config-layout.test.js @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const DOCKERFILE = path.join(import.meta.dirname, "..", "Dockerfile"); +const BASE_DOCKERFILE = path.join(import.meta.dirname, "..", "Dockerfile.base"); + +describe("OpenClaw config layout (#719)", () => { + it("promotes OPENCLAW_CONFIG_PATH into the image environment", () => { + const src = fs.readFileSync(DOCKERFILE, "utf-8"); + + expect(src).toMatch(/OPENCLAW_STATE_DIR=\/sandbox\/\.openclaw/); + expect(src).toMatch(/OPENCLAW_CONFIG_PATH=\/sandbox\/\.openclaw-data\/config\/openclaw\.json/); + }); + + it("creates the openclaw.json wrapper symlink in both Dockerfiles", () => { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); + const baseDockerfile = fs.readFileSync(BASE_DOCKERFILE, "utf-8"); + + expect(dockerfile).toMatch(/os\.symlink\(config_path, wrapper_path\)/); + expect(baseDockerfile).toContain( + "ln -s /sandbox/.openclaw-data/config/openclaw.json /sandbox/.openclaw/openclaw.json", + ); + }); + + it("shares the live config directory between sandbox and gateway", () => { + const src = fs.readFileSync(DOCKERFILE, "utf-8"); + + expect(src).toContain( + "chown sandbox:gateway /sandbox/.openclaw-data/config /sandbox/.openclaw-data/config/openclaw.json", + ); + expect(src).toContain("chmod 2775 /sandbox/.openclaw-data/config"); + expect(src).toContain("chmod 664 /sandbox/.openclaw-data/config/openclaw.json"); + }); +}); From 3a7ade23eef433791809988b09d49f4c0c76b2ab Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Sun, 5 Apr 2026 18:27:43 +0800 Subject: [PATCH 5/5] fix(onboard): address PR 1497 review follow-ups --- bin/lib/onboard.js | 42 +++++++++++----- docs/security/best-practices.md | 2 +- scripts/nemoclaw-start.sh | 86 +++++++++++++++++++++++---------- test/nemoclaw-start.test.js | 25 ++++++++++ test/onboard.test.js | 21 ++++++++ 5 files changed, 137 insertions(+), 39 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index fef57d4b2..92df5395f 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1071,18 +1071,35 @@ async function promptWebSearchRecovery(provider, validation) { if (recovery.kind === "credential") { console.log(` ${label} rejected that API key.`); + const answer = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")) + .trim() + .toLowerCase(); + if (answer === "back") return "back"; + if (answer === "exit" || answer === "quit") { + exitOnboardFromPrompt(); + } + return "credential"; } else if (recovery.kind === "transport") { console.log(getTransportRecoveryMessage(recovery.failure || validation)); + const answer = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")) + .trim() + .toLowerCase(); + if (answer === "back") return "back"; + if (answer === "exit" || answer === "quit") { + exitOnboardFromPrompt(); + } + return "retry"; } else { console.log(` ${label} validation did not succeed.`); + const answer = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")) + .trim() + .toLowerCase(); + if (answer === "back") return "back"; + if (answer === "exit" || answer === "quit") { + exitOnboardFromPrompt(); + } + return "credential"; } - - const answer = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")).trim().toLowerCase(); - if (answer === "back") return "back"; - if (answer === "exit" || answer === "quit") { - exitOnboardFromPrompt(); - } - return "retry"; } async function promptWebSearchApiKey(provider) { @@ -1134,9 +1151,10 @@ async function ensureValidatedWebSearchCredential(provider) { const action = await promptWebSearchRecovery(provider, validation); if (action === "back") return null; - - apiKey = null; - usingSavedKey = false; + if (action === "credential") { + apiKey = null; + usingSavedKey = false; + } } } @@ -1230,13 +1248,13 @@ async function configureWebSearch(existingConfig = null) { const enableAnswer = await prompt(" Enable Web Search? [y/N]: "); if (!isAffirmativeAnswer(enableAnswer)) { - return null; + return { fetchEnabled: false }; } while (true) { const provider = await promptWebSearchProviderChoice(); if (!provider) { - return null; + return { fetchEnabled: false }; } printWebSearchExposureWarning(provider); diff --git a/docs/security/best-practices.md b/docs/security/best-practices.md index d08118bda..e678d45ba 100644 --- a/docs/security/best-practices.md +++ b/docs/security/best-practices.md @@ -242,7 +242,7 @@ Multiple defense layers protect this directory: |---|---| | Default | The container mounts `/sandbox/.openclaw` as a read-only, root-owned wrapper with immutable symlinks. The live config file lives in `/sandbox/.openclaw-data/config/openclaw.json`, exposed at `/sandbox/.openclaw/openclaw.json`, and `/sandbox/.openclaw-data` remains writable. | | What you can change | Move `/sandbox/.openclaw` from `read_only` to `read_write` in the policy file. | -| Risk if relaxed | A writable `.openclaw` directory lets the agent replace wrapper symlinks and redirect config-backed paths to attacker-controlled locations. This is the single most dangerous filesystem change. | +| Risk if relaxed | A writable `.openclaw` directory lets the agent replace wrapper symlinks and redirect config-backed paths to attacker-controlled locations, which can enable arbitrary code execution, persistence, or privilege escalation. | | Recommendation | Never make `/sandbox/.openclaw` writable. | ### Writable Paths diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 8f4ae6c52..f815e9498 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -102,6 +102,20 @@ OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere OPENCLAW_STATE_DIR="${OPENCLAW_STATE_DIR:-/sandbox/.openclaw}" OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-/sandbox/.openclaw-data/config/openclaw.json}" OPENCLAW_CONFIG_SYMLINK="${OPENCLAW_STATE_DIR}/openclaw.json" +OPENCLAW_WRAPPER_ENTRIES=( + openclaw.json + agents + extensions + workspace + skills + hooks + identity + devices + canvas + cron + memory + update-check.json +) export OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH # ── OpenClaw layout verification ──────────────────────────────── @@ -109,22 +123,58 @@ export OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH # config file is writable and lives outside the wrapper at # ${OPENCLAW_CONFIG_PATH}. -verify_config_layout() { - local config_dir target - config_dir="$(dirname "$OPENCLAW_CONFIG_PATH")" - if [ ! -d "$config_dir" ]; then - echo "[SECURITY] OpenClaw config directory missing: ${config_dir}" >&2 +verify_wrapper_symlink() { + local name="$1" + local expected="$2" + local entry="${OPENCLAW_STATE_DIR}/${name}" + local target + + if [ ! -L "$entry" ]; then + echo "[SECURITY] OpenClaw config wrapper entry is not a symlink: ${entry}" >&2 + return 1 + fi + target="$(readlink -f "$entry" 2>/dev/null || true)" + if [ "$target" != "$expected" ]; then + echo "[SECURITY] OpenClaw config wrapper target mismatch: ${entry} -> ${target} (expected ${expected})" >&2 return 1 fi - if [ ! -L "$OPENCLAW_CONFIG_SYMLINK" ]; then - echo "[SECURITY] OpenClaw config wrapper is not a symlink: ${OPENCLAW_CONFIG_SYMLINK}" >&2 +} + +verify_config_layout() { + local config_dir openclaw_data_dir name expected entry target + if [ ! -d "$OPENCLAW_STATE_DIR" ]; then + echo "[SECURITY] OpenClaw config wrapper directory missing: ${OPENCLAW_STATE_DIR}" >&2 return 1 fi - target="$(readlink -f "$OPENCLAW_CONFIG_SYMLINK" 2>/dev/null || true)" - if [ "$target" != "$OPENCLAW_CONFIG_PATH" ]; then - echo "[SECURITY] OpenClaw config symlink target mismatch: ${target} (expected ${OPENCLAW_CONFIG_PATH})" >&2 + config_dir="$(dirname "$OPENCLAW_CONFIG_PATH")" + openclaw_data_dir="$(dirname "$config_dir")" + if [ ! -d "$config_dir" ]; then + echo "[SECURITY] OpenClaw config directory missing: ${config_dir}" >&2 return 1 fi + + for name in "${OPENCLAW_WRAPPER_ENTRIES[@]}"; do + case "$name" in + openclaw.json) expected="$OPENCLAW_CONFIG_PATH" ;; + *) expected="${openclaw_data_dir}/${name}" ;; + esac + verify_wrapper_symlink "$name" "$expected" || return 1 + done + + for entry in "${OPENCLAW_STATE_DIR}"/*; do + [ -L "$entry" ] || continue + name="$(basename "$entry")" + case " ${OPENCLAW_WRAPPER_ENTRIES[*]} " in + *" ${name} "*) continue ;; + esac + target="$(readlink -f "$entry" 2>/dev/null || true)" + expected="${openclaw_data_dir}/${name}" + if [ "$target" != "$expected" ]; then + echo "[SECURITY] OpenClaw config wrapper target mismatch: ${entry} -> ${target} (expected ${expected})" >&2 + return 1 + fi + done + if [ ! -f "$OPENCLAW_CONFIG_PATH" ]; then echo "[SECURITY] OpenClaw config file missing: ${OPENCLAW_CONFIG_PATH}" >&2 return 1 @@ -410,22 +460,6 @@ touch /tmp/auto-pair.log chown sandbox:sandbox /tmp/auto-pair.log chmod 600 /tmp/auto-pair.log -# Verify ALL symlinks in .openclaw point to expected .openclaw-data targets. -# Dynamic scan so future OpenClaw symlinks are covered automatically. -for entry in /sandbox/.openclaw/*; do - [ -L "$entry" ] || continue - name="$(basename "$entry")" - target="$(readlink -f "$entry" 2>/dev/null || true)" - case "$name" in - openclaw.json) expected="$OPENCLAW_CONFIG_PATH" ;; - *) expected="/sandbox/.openclaw-data/$name" ;; - esac - if [ "$target" != "$expected" ]; then - echo "[SECURITY] Symlink $entry points to unexpected target: $target (expected $expected)" >&2 - exit 1 - fi -done - # Lock .openclaw directory after symlink validation: set the immutable flag # so symlinks cannot be swapped at runtime even if DAC or Landlock are # bypassed. chattr requires cap_linux_immutable which the entrypoint has diff --git a/test/nemoclaw-start.test.js b/test/nemoclaw-start.test.js index a0ac34a1c..08431dd9e 100644 --- a/test/nemoclaw-start.test.js +++ b/test/nemoclaw-start.test.js @@ -34,6 +34,31 @@ describe("nemoclaw-start non-root fallback", () => { expect(calls.length).toBeGreaterThanOrEqual(3); // definition + 2 call sites }); + it("validates the full .openclaw wrapper layout, not just openclaw.json", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + expect(src).toMatch(/OPENCLAW_WRAPPER_ENTRIES=\(/); + for (const entry of [ + "openclaw.json", + "agents", + "extensions", + "workspace", + "skills", + "hooks", + "identity", + "devices", + "canvas", + "cron", + "memory", + "update-check.json", + ]) { + expect(src).toContain(entry); + } + expect(src).toMatch(/for name in "\$\{OPENCLAW_WRAPPER_ENTRIES\[@\]\}"; do/); + expect(src).toContain("OpenClaw config wrapper entry is not a symlink"); + expect(src).toContain("OpenClaw config wrapper target mismatch"); + }); + it("exports the live OpenClaw config path under .openclaw-data", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); diff --git a/test/onboard.test.js b/test/onboard.test.js index d740c5950..9fc4b2519 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1186,6 +1186,19 @@ const { setupInference } = require(${onboardPath}); assert.match(source, /if \(isNonInteractive\(\)\) return null;/); }); + it("keeps the current web-search key on transport retries", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + + assert.match(source, /if \(recovery\.kind === "transport"\) \{[\s\S]*return "retry";\s*\}/); + assert.match( + source, + /const action = await promptWebSearchRecovery\(provider, validation\);\s*if \(action === "back"\) return null;\s*if \(action === "credential"\) \{\s*apiKey = null;\s*usingSavedKey = false;\s*\}/s, + ); + }); + it("preserves an explicitly disabled web-search choice during resume", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), @@ -1201,6 +1214,14 @@ const { setupInference } = require(${onboardPath}); /if \(persistedWebSearchConfigRaw && persistedWebSearchConfigRaw\.fetchEnabled === false\) \{/, ); assert.match(source, /note\(" \[resume\] Keeping Web Search disabled\."\);/); + assert.match( + source, + /if \(!isAffirmativeAnswer\(enableAnswer\)\) \{\s*return \{ fetchEnabled: false \};\s*\}/, + ); + assert.match( + source, + /const provider = await promptWebSearchProviderChoice\(\);\s*if \(!provider\) \{\s*return \{ fetchEnabled: false \};\s*\}/s, + ); }); it("prints numbered step headers even when onboarding skips resumed steps", () => {