diff --git a/Dockerfile b/Dockerfile index 134e41e39..aa9117105 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,14 +87,17 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_WEB_CONFIG_B64=${NEMOCLAW_WEB_CONFIG_B64} \ NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ - NEMOCLAW_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 "\ @@ -142,52 +145,41 @@ 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 {}); \ -path = os.path.expanduser('~/.openclaw/openclaw.json'); \ -json.dump(config, open(path, 'w'), indent=2); \ -os.chmod(path, 0o600)" +config.update(web_config if isinstance(web_config, dict) else {}); \ +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 \ && rm -rf /root/.npm /sandbox/.npm \ && 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 104f5e6dd..10d9dbf58 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 \ @@ -105,6 +107,7 @@ RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ /sandbox/.openclaw-data/telegram \ /sandbox/.openclaw-data/credentials \ && 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 0e370aa29..8d199c8ae 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -105,7 +105,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: { @@ -674,10 +673,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 @@ -710,9 +710,21 @@ function isAffirmativeAnswer(value) { ); } -function printBraveExposureWarning() { +function normalizeWebSearchConfigValue(config) { + return webSearch.normalizeWebSearchConfig(config); +} + +function normalizePersistedWebSearchConfigValue(config) { + return webSearch.normalizePersistedWebSearchConfig(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(""); @@ -737,118 +749,254 @@ 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.`); + 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(validation)); + 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(" Brave Search validation did not succeed."); - } - - const answer = (await prompt(" Type 'retry', 'skip', or 'exit' [retry]: ")).trim().toLowerCase(); - if (answer === "skip") return "skip"; - if (answer === "exit" || answer === "quit") { - exitOnboardFromPrompt(); + 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"; } - 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(); + if (isNonInteractive()) { + console.error(` ${credentialEnv} is required for ${label} in non-interactive mode.`); + return null; + } + 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}`); } + if (isNonInteractive()) return null; - const action = await promptBraveSearchRecovery(validation); - if (action === "skip") { - console.log(" Skipping Brave Web Search setup."); - console.log(""); + const action = await promptWebSearchRecovery(provider, validation); + if (action === "back") return null; + if (action === "credential") { + 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; } - apiKey = null; - usingSavedKey = false; + 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; + return { fetchEnabled: false }; } - const braveApiKey = await ensureValidatedBraveSearchCredential(); - if (!braveApiKey) { - return null; - } + while (true) { + const provider = await promptWebSearchProviderChoice(); + if (!provider) { + return { fetchEnabled: false }; + } - 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) { @@ -908,6 +1056,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( @@ -938,8 +1087,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, @@ -2088,12 +2241,20 @@ async function createSandbox( } 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); + } } const activeMessagingChannels = messagingTokenDefs .filter(({ token }) => !!token) @@ -3494,7 +3655,7 @@ async function presetsCheckboxSelector(allPresets, initialSelected) { 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(8, 8, "Policy presets"); @@ -3503,7 +3664,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); @@ -4024,16 +4187,26 @@ async function onboard(opts = {}) { break; } - if (webSearchConfig) { - note(" [resume] Revalidating Brave Search configuration."); - const braveApiKey = await ensureValidatedBraveSearchCredential(); - if (braveApiKey) { - webSearchConfig = { fetchEnabled: true }; + 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); + 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 19fc34f76..f77f183df 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -69,12 +69,13 @@ $ nemoclaw onboard [--non-interactive] [--resume] [--from ] 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 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: @@ -88,14 +89,17 @@ 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`. +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. 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/docs/security/best-practices.md b/docs/security/best-practices.md index 5491fa99a..901667f53 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, which can enable arbitrary code execution, persistence, or privilege escalation. | | 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 6552b4d8f..df74d6682 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/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/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index ea00b9f9f..61540f19c 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,84 @@ 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 +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 ──────────────────────────────── +# 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_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 +} -# ── 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 +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 - 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 + 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 fi } @@ -118,8 +184,9 @@ verify_config_integrity() { _read_gateway_token() { python3 - <<'PYTOKEN' import json +import os try: - with open('/sandbox/.openclaw/openclaw.json') as f: + with open(os.environ.get('OPENCLAW_CONFIG_PATH', '/sandbox/.openclaw-data/config/openclaw.json')) as f: cfg = json.load(f) print(cfg.get('gateway', {}).get('auth', {}).get('token', '')) except Exception: @@ -275,7 +342,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': { @@ -518,8 +586,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 export_gateway_token @@ -555,14 +623,14 @@ 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 export_gateway_token install_configure_guard # Inject messaging channel config if provider tokens are present. -# Must run AFTER integrity check (to detect build-time tampering) and -# BEFORE chattr +i (which locks the config permanently). +# Must run AFTER the layout check and BEFORE chattr +i +# (which locks the config permanently). configure_messaging_channels # Write auth profile as sandbox user (needs writable .openclaw-data) diff --git a/src/lib/onboard-session.test.ts b/src/lib/onboard-session.test.ts index 6156a574e..fad0a42bc 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 preserves 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).toEqual({ provider: "brave", fetchEnabled: false }); + }); + 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,19 @@ 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 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"); expect(loaded.steps.inference.error).not.toContain("ghp_1234567890123456789012345"); @@ -207,13 +224,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 65a8febc4..122887209 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 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"); @@ -56,7 +60,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; @@ -85,7 +89,7 @@ export interface SessionUpdates { credentialEnv?: string; preferredInferenceApi?: string; nimContainer?: string; - webSearchConfig?: WebSearchConfig | null; + webSearchConfig?: PersistedWebSearchConfig | null; policyPresets?: string[]; metadata?: { gatewayName?: string; fromDockerfile?: string | null }; } @@ -120,13 +124,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)=\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, "") @@ -193,10 +214,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: normalizePersistedWebSearchConfig(overrides.webSearchConfig), policyPresets: Array.isArray(overrides.policyPresets) ? overrides.policyPresets.filter((value) => typeof value === "string") : null, @@ -228,11 +246,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: normalizePersistedWebSearchConfig(d.webSearchConfig), policyPresets: Array.isArray(d.policyPresets) ? (d.policyPresets as unknown[]).filter((value) => typeof value === "string") as string[] : null, @@ -418,10 +432,15 @@ 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 = normalizePersistedWebSearchConfig(updates.webSearchConfig); + if (normalizedWebSearchConfig) { + safe.webSearchConfig = normalizedWebSearchConfig; + } + } } if (Array.isArray(updates.policyPresets)) { safe.policyPresets = updates.policyPresets.filter((value) => typeof value === "string"); @@ -517,6 +536,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..db3c83edd 100644 --- a/src/lib/web-search.test.ts +++ b/src/lib/web-search.test.ts @@ -4,8 +4,11 @@ import { describe, expect, it } from "vitest"; import { + buildWebSearchConfigFragment, buildWebSearchDockerConfig, - getBraveExposureWarningLines, + DEFAULT_GEMINI_WEB_SEARCH_MODEL, + getWebSearchExposureWarningLines, + normalizeWebSearchConfig, } from "./web-search"; describe("web-search helpers", () => { @@ -18,23 +21,118 @@ 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("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"), + ).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: { + model: DEFAULT_GEMINI_WEB_SEARCH_MODEL, + apiKey: "g-x", + }, + }, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: "gemini", + }, + fetch: { + enabled: true, + }, + }, + }, + }); + }); + + 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"); 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..08ded129c 100644 --- a/src/lib/web-search.ts +++ b/src/lib/web-search.ts @@ -1,33 +1,165 @@ // 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 DisabledWebSearchConfig { + provider?: WebSearchProvider; + fetchEnabled: false; +} + +export type PersistedWebSearchConfig = WebSearchConfig | DisabledWebSearchConfig; + +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 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, + 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 [ - "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: { + ...(normalized.provider === "gemini" + ? { model: DEFAULT_GEMINI_WEB_SEARCH_MODEL } + : {}), + ...(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/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index a1eb6726b..33b6e2a3b 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 2973fc685..018a99f8a 100644 --- a/test/nemoclaw-start.test.js +++ b/test/nemoclaw-start.test.js @@ -16,24 +16,65 @@ 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("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"); + + 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"); @@ -199,11 +240,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/onboard.test.js b/test/onboard.test.js index 1f59446fc..1d0106f2e 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -43,6 +43,26 @@ import { import { stageOptimizedSandboxBuildContext } from "../bin/lib/sandbox-build-context"; 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( @@ -230,53 +250,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, - { fetchEnabled: true }, - ); - const patched = fs.readFileSync(dockerfilePath, "utf8"); - const expected = buildWebSearchDockerConfig({ 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; + [ + { + 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", () => { @@ -1304,6 +1329,57 @@ 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("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"), + "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\."\);/); + 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", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), 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"); + }); +}); diff --git a/test/policies.test.js b/test/policies.test.js index 27c9b6d57..a89256816 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 11 presets", () => { + it("returns all 13 presets", () => { const presets = policies.listPresets(); - expect(presets.length).toBe(11); + expect(presets.length).toBe(13); }); it("each preset has name and description", () => { @@ -115,12 +115,14 @@ describe("policies", () => { "brew", "discord", "docker", + "gemini", "huggingface", "jira", "npm", "outlook", "pypi", "slack", + "tavily", "telegram", ]; expect(names).toEqual(expected); @@ -160,6 +162,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);