diff --git a/.agents/skills/nemoclaw-user-configure-security/references/best-practices.md b/.agents/skills/nemoclaw-user-configure-security/references/best-practices.md index c4fcfb30971..c055e36b7c0 100644 --- a/.agents/skills/nemoclaw-user-configure-security/references/best-practices.md +++ b/.agents/skills/nemoclaw-user-configure-security/references/best-practices.md @@ -207,23 +207,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 @@ -378,7 +379,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/.agents/skills/nemoclaw-user-reference/references/commands.md b/.agents/skills/nemoclaw-user-reference/references/commands.md index 7f8e9c5fc74..3baf33ca8d3 100644 --- a/.agents/skills/nemoclaw-user-reference/references/commands.md +++ b/.agents/skills/nemoclaw-user-reference/references/commands.md @@ -60,6 +60,7 @@ $ NEMOCLAW_SINGLE_SESSION=1 curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash 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`. For file permissions, plaintext storage behavior, and hardening guidance, see Credential Storage (see the `nemoclaw-user-configure-security` skill). +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. After provider selection, the wizard prompts for a **policy tier** that controls the default set of network policy presets applied to the sandbox. @@ -80,10 +81,9 @@ In non-interactive mode, set the tier with `NEMOCLAW_POLICY_TIER` (default: `bal $ NEMOCLAW_POLICY_TIER=restricted nemoclaw onboard --non-interactive --yes-i-accept-third-party-software ``` -If you enable Brave Search during onboarding, NemoClaw currently stores the Brave API key in the sandbox's OpenClaw configuration. -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. +If you enable web search during onboarding, NemoClaw stores an OpenShell environment resolver in the sandbox's OpenClaw configuration as that provider's `apiKey`. +That keeps the raw key out of `openclaw.json`, but the OpenClaw agent can still resolve and read the key at runtime. +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,14 +97,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/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md index caa146c90ad..ab8952e3ce5 100644 --- a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md +++ b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md @@ -350,6 +350,18 @@ The status command detects the sandbox context and reports "active (inside sandb Run `openshell sandbox list` on the host to check the underlying sandbox state. +### `openclaw update` hangs or times out inside the sandbox + +This is expected for the current NemoClaw deployment model. +NemoClaw installs `openclaw` into the sandbox image at build time, so the CLI is image-pinned rather than updated in place inside a running sandbox. + +Do not run `openclaw update` inside the sandbox. +Instead: + +1. Upgrade to a NemoClaw release that includes the newer `openclaw` version. +2. If you build NemoClaw from source, bump the pinned `openclaw` version in `Dockerfile.base` and rebuild the sandbox base image. +3. Back up any workspace files you need, then recreate the sandbox so it uses the rebuilt image. + ### Inference requests time out Verify that the inference provider endpoint is reachable from the host. diff --git a/Dockerfile b/Dockerfile index af0918bd302..e1ccb4e0741 100644 --- a/Dockerfile +++ b/Dockerfile @@ -190,11 +190,9 @@ ARG NEMOCLAW_BUILD_ID=default # before running `nemoclaw onboard`. See #1409. ARG NEMOCLAW_PROXY_HOST=10.200.0.1 ARG NEMOCLAW_PROXY_PORT=3128 -# Non-secret flag: set to "1" when the user configured Brave Search during -# onboard. Controls whether the web search block is written to openclaw.json. -# The actual API key is injected at runtime via openshell:resolve:env, never -# baked into the image. -ARG NEMOCLAW_WEB_SEARCH_ENABLED=0 +# Base64-encoded non-secret OpenClaw web-search config fragment. API keys are +# written as openshell:resolve:env references and injected at runtime. +ARG NEMOCLAW_WEB_CONFIG_B64=e30= # SECURITY: Promote build-args to env vars so the Python script reads them # via os.environ, never via string interpolation into Python source code. @@ -215,14 +213,17 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} \ - NEMOCLAW_WEB_SEARCH_ENABLED=${NEMOCLAW_WEB_SEARCH_ENABLED} + NEMOCLAW_WEB_CONFIG_B64=${NEMOCLAW_WEB_CONFIG_B64} \ + 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 "\ @@ -276,36 +277,31 @@ config = { \ 'auth': {'token': secrets.token_hex(32)} \ } \ }; \ -config.update({ \ - 'tools': { \ - 'web': { \ - 'search': { \ - 'enabled': True, \ - 'provider': 'brave', \ - 'apiKey': 'openshell:resolve:env:BRAVE_API_KEY' \ - }, \ - 'fetch': {'enabled': True} \ - } \ - } \ -}) if os.environ.get('NEMOCLAW_WEB_SEARCH_ENABLED', '') == '1' else None; \ -path = os.path.expanduser('~/.openclaw/openclaw.json'); \ -json.dump(config, open(path, 'w'), indent=2); \ -os.chmod(path, 0o600)" +web_config = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_WEB_CONFIG_B64', 'e30=') or 'e30=').decode('utf-8')); \ +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); \ +os.remove(wrapper_path) if os.path.lexists(wrapper_path) and not os.path.islink(wrapper_path) else None; \ +os.remove(wrapper_path) if os.path.islink(wrapper_path) and os.path.realpath(wrapper_path) != config_path else None; \ +os.symlink(config_path, wrapper_path) if not os.path.islink(wrapper_path) else None; \ +fh = open(config_path, 'w', encoding='utf-8'); \ +json.dump(config, fh, indent=2); \ +fh.write('\n'); \ +fh.close(); \ +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 @@ -369,14 +365,9 @@ 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 # DAC-protect .nemoclaw directory: /sandbox/.nemoclaw is Landlock read_write # (for plugin state/config), but the parent and blueprints are immutable at diff --git a/Dockerfile.base b/Dockerfile.base index 1416829d334..24b9f280d04 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -88,12 +88,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 \ @@ -109,6 +111,7 @@ RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ /sandbox/.openclaw-data/sandbox \ /sandbox/.openclaw-data/telegram \ && 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/docs/reference/commands.md b/docs/reference/commands.md index 6304e9f129f..df87496f8e9 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -82,6 +82,7 @@ $ NEMOCLAW_SINGLE_SESSION=1 curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash 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`. For file permissions, plaintext storage behavior, and hardening guidance, see [Credential Storage](../security/credential-storage.md). +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. After provider selection, the wizard prompts for a **policy tier** that controls the default set of network policy presets applied to the sandbox. @@ -102,10 +103,9 @@ In non-interactive mode, set the tier with `NEMOCLAW_POLICY_TIER` (default: `bal $ NEMOCLAW_POLICY_TIER=restricted nemoclaw onboard --non-interactive --yes-i-accept-third-party-software ``` -If you enable Brave Search during onboarding, NemoClaw currently stores the Brave API key in the sandbox's OpenClaw configuration. -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. +If you enable web search during onboarding, NemoClaw stores an OpenShell environment resolver in the sandbox's OpenClaw configuration as that provider's `apiKey`. +That keeps the raw key out of `openclaw.json`, but the OpenClaw agent can still resolve and read the key at runtime. +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: @@ -119,14 +119,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 b0f4c6a082c..b723a10263c 100644 --- a/docs/security/best-practices.md +++ b/docs/security/best-practices.md @@ -227,23 +227,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 @@ -398,7 +399,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 85f342be0e2..9531786678e 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -35,15 +35,15 @@ filesystem_policy: # their own runtime environment. Writable state # is restricted to /sandbox/.openclaw-data. # Ref: https://github.com/NVIDIA/NemoClaw/issues/804 - - /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: - /tmp - /dev/null - - /sandbox/.openclaw-data # Writable agent/plugin state (symlinked from .openclaw) + - /sandbox/.openclaw-data # Writable state, including the active OpenClaw config - /sandbox/.nemoclaw # Plugin state and config (state.ts, config.ts). # Blueprints are root-owned; sticky bit (1755) # on parent prevents rename/delete (#1607). diff --git a/nemoclaw-blueprint/policies/presets/gemini.yaml b/nemoclaw-blueprint/policies/presets/gemini.yaml new file mode 100644 index 00000000000..508f886b692 --- /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 00000000000..66343a679be --- /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 296948d13e8..022ce84e43c 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. # NEMOCLAW_MODEL_OVERRIDE Override the primary model at startup without rebuilding # the sandbox image. Must match the model configured on # the gateway via `openshell inference set`. @@ -185,21 +188,84 @@ fi PUBLIC_PORT="$_DASHBOARD_PORT" OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere _SANDBOX_HOME="/sandbox" # Home dir for the sandbox user (useradd -d /sandbox in Dockerfile.base) +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}. -# ── 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_wrapper_symlink() { + local name="$1" + local expected="$2" + local entry="${OPENCLAW_STATE_DIR}/${name}" + local target -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 + 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 ! (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 +} + +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 + 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 } @@ -207,9 +273,7 @@ verify_config_integrity() { # ── Runtime model/provider override ────────────────────────────── # Patches openclaw.json at startup when NEMOCLAW_MODEL_OVERRIDE is set, # allowing model or provider changes without rebuilding the sandbox image. -# Runs AFTER integrity check (detects build-time tampering) and BEFORE -# chattr +i (locks the file permanently). Recomputes the config hash so -# future integrity checks pass. +# Runs after the wrapper layout is verified and before the gateway launches. # # SECURITY: These env vars come from the host (Docker/OpenShell), not from # inside the sandbox. The agent cannot set them. Landlock locks the file @@ -225,20 +289,17 @@ apply_model_override() { || [ -n "${NEMOCLAW_REASONING:-}" ] \ || return 0 - # SECURITY: Only root can write to /sandbox/.openclaw (root:root 444). - # In non-root mode the sandbox user cannot modify the config. + # Keep host-supplied runtime overrides root-only so sandbox commands cannot + # silently mutate the configured model through environment variables. if [ "$(id -u)" -ne 0 ]; then printf '[SECURITY] Model/inference overrides ignored — requires root (non-root mode cannot write to config)\n' >&2 return 0 fi - local config_file="/sandbox/.openclaw/openclaw.json" - local hash_file="/sandbox/.openclaw/.config-hash" + local config_file="$OPENCLAW_CONFIG_PATH" - # SECURITY: Refuse to write through symlinks to prevent symlink-following attacks. - # Symlink validation (validate_openclaw_symlinks) runs later, so guard here too. - if [ -L "$config_file" ] || [ -L "$hash_file" ]; then - printf '[SECURITY] Refusing model override — config or hash path is a symlink\n' >&2 + if [ ! -f "$config_file" ]; then + printf '[SECURITY] Refusing model override — config file missing: %s\n' "$config_file" >&2 return 1 fi @@ -335,16 +396,13 @@ with open(config_file, "w") as f: json.dump(cfg, f, indent=2) PYOVERRIDE - # Recompute config hash so integrity check passes on next startup - (cd /sandbox/.openclaw && sha256sum openclaw.json >"$hash_file") - printf '[SECURITY] Config hash recomputed after model override\n' >&2 + printf '[config] Model override written to %s\n' "$config_file" >&2 } # ── Runtime CORS origin override ────────────────────────────────── # Adds a browser origin to gateway.controlUi.allowedOrigins at startup # without rebuilding the sandbox image. Useful for custom domains/ports. -# Same trust model as model override: host-set env var, applied before -# chattr +i, hash recomputed. +# Same trust model as model override: host-set env var applied before the gateway launches. # Ref: https://github.com/NVIDIA/NemoClaw/issues/719 apply_cors_override() { @@ -355,11 +413,10 @@ apply_cors_override() { return 0 fi - local config_file="/sandbox/.openclaw/openclaw.json" - local hash_file="/sandbox/.openclaw/.config-hash" + local config_file="$OPENCLAW_CONFIG_PATH" - if [ -L "$config_file" ] || [ -L "$hash_file" ]; then - printf '[SECURITY] Refusing CORS override — config or hash path is a symlink\n' >&2 + if [ ! -f "$config_file" ]; then + printf '[SECURITY] Refusing CORS override — config file missing: %s\n' "$config_file" >&2 return 1 fi @@ -397,15 +454,15 @@ with open(config_file, "w") as f: json.dump(cfg, f, indent=2) PYCORS - (cd /sandbox/.openclaw && sha256sum openclaw.json >"$hash_file") - printf '[config] Config hash recomputed after CORS override\n' >&2 + printf '[config] CORS override written to %s\n' "$config_file" >&2 } _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/openclaw.json')) as f: cfg = json.load(f) print(cfg.get('gateway', {}).get('auth', {}).get('token', '')) except Exception: @@ -528,7 +585,10 @@ validate_openclaw_symlinks() { [ -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 return 1 @@ -577,7 +637,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': { @@ -817,8 +878,8 @@ fi 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 apply_model_override @@ -918,16 +979,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 apply_model_override apply_cors_override 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). configure_messaging_channels # Write auth profile as sandbox user (needs writable .openclaw-data) @@ -949,10 +1008,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. -validate_openclaw_symlinks - # 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/src/lib/onboard-session.test.ts b/src/lib/onboard-session.test.ts index 3394e33243d..30bc72dea12 100644 --- a/src/lib/onboard-session.test.ts +++ b/src/lib/onboard-session.test.ts @@ -122,18 +122,15 @@ describe("onboard session", () => { expect(loaded.metadata.token).toBeUndefined(); }); - it("persists and clears web search config through safe session updates", () => { - session.saveSession(session.createSession()); - session.markStepComplete("provider_selection", { - webSearchConfig: { fetchEnabled: true }, - }); + 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({ fetchEnabled: true }); + expect(loaded.webSearchConfig).toEqual({ provider: "brave", fetchEnabled: true }); - session.completeSession({ webSearchConfig: null }); + 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", () => { @@ -291,13 +288,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"); @@ -305,13 +308,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 ef2cfbb28dd..c9594a2995f 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"); @@ -57,7 +61,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; @@ -86,7 +90,7 @@ export interface SessionUpdates { credentialEnv?: string; preferredInferenceApi?: string; nimContainer?: string; - webSearchConfig?: WebSearchConfig | null; + webSearchConfig?: PersistedWebSearchConfig | null; policyPresets?: string[]; metadata?: { gatewayName?: string; fromDockerfile?: string | null }; } @@ -122,13 +126,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, "") @@ -196,10 +217,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, @@ -232,11 +250,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, @@ -542,10 +556,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"); @@ -654,6 +673,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/onboard.ts b/src/lib/onboard.ts index c6f9330cc00..6e5c888d702 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -158,7 +158,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://brave.com/search/api/"; const REMOTE_PROVIDER_CONFIG = { build: { @@ -948,10 +947,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 @@ -984,6 +984,26 @@ function isAffirmativeAnswer(value) { ); } +function normalizePersistedWebSearchConfigValue(config) { + return webSearch.normalizePersistedWebSearchConfig(config); +} + +function normalizeWebSearchConfigValue(config) { + return webSearch.normalizeWebSearchConfig(config); +} + +function getWebSearchProviderMetadata(provider) { + return webSearch.getWebSearchProvider(provider); +} + +function printWebSearchExposureWarning(provider) { + console.log(""); + for (const line of webSearch.getWebSearchExposureWarningLines(provider)) { + console.log(` ${line}`); + } + console.log(""); +} + function validateBraveSearchApiKey(apiKey) { return runCurlProbe([ "-sS", @@ -1003,128 +1023,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(nonInteractive = isNonInteractive()) { - const savedApiKey = getCredential(webSearch.BRAVE_API_KEY_ENV); - let apiKey = savedApiKey || normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); - let usingSavedKey = Boolean(savedApiKey); +async function ensureValidatedWebSearchCredential(provider) { + const { credentialEnv, label } = getWebSearchProviderMetadata(provider); + let apiKey = getCredential(credentialEnv); + let usingSavedKey = Boolean(apiKey); while (true) { if (!apiKey) { - if (nonInteractive) { - throw new Error( - "Brave Search requires BRAVE_API_KEY or a saved Brave Search credential in non-interactive mode.", - ); + if (isNonInteractive()) { + console.error(` ${credentialEnv} is required for ${label} in non-interactive mode.`); + return null; } - 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}`); } + if (isNonInteractive()) return null; - if (nonInteractive) { - throw new Error( - validation.message || - "Brave Search API key validation failed in non-interactive mode.", - ); + const action = await promptWebSearchRecovery(provider, validation); + if (action === "back") return null; + if (action === "credential") { + apiKey = null; + usingSavedKey = false; } + } +} - const action = await promptBraveSearchRecovery(validation); - if (action === "skip") { - console.log(" Skipping Brave Web Search setup."); - console.log(""); +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."); - 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 }; } - 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) { @@ -1186,6 +1332,7 @@ function patchStagedDockerfile( ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); + const normalizedWebSearchConfig = normalizeWebSearchConfigValue(webSearchConfig); let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); // Pin the base image to a specific digest when available (#1904). // The ref must come from pullAndResolveBaseImageDigest() — never from @@ -1272,8 +1419,15 @@ function patchStagedDockerfile( ); } dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=.*$/m, - `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${webSearchConfig ? "1" : "0"}`, + /^ARG NEMOCLAW_WEB_CONFIG_B64=.*$/m, + `ARG NEMOCLAW_WEB_CONFIG_B64=${webSearch.buildWebSearchDockerConfig( + normalizedWebSearchConfig, + normalizedWebSearchConfig + ? getCredential( + getWebSearchProviderMetadata(normalizedWebSearchConfig.provider).credentialEnv, + ) + : null, + )}`, ); // Onboard flow expects immediate dashboard access without device pairing, // so disable device auth for images built during onboard (see #1217). @@ -3032,11 +3186,15 @@ async function createSandbox( }, ].filter(({ envKey }) => !enabledEnvKeys || enabledEnvKeys.has(envKey)); - if (webSearchConfig) { + const normalizedWebSearchConfig = normalizeWebSearchConfigValue(webSearchConfig); + if (normalizedWebSearchConfig) { + const { credentialEnv, provider } = getWebSearchProviderMetadata( + normalizedWebSearchConfig.provider, + ); messagingTokenDefs.push({ - name: `${sandboxName}-brave-search`, - envKey: webSearch.BRAVE_API_KEY_ENV, - token: getCredential(webSearch.BRAVE_API_KEY_ENV), + name: `${sandboxName}-${provider}-search`, + envKey: credentialEnv, + token: getCredential(credentialEnv), }); } const hasMessagingTokens = messagingTokenDefs.some(({ token }) => !!token); @@ -3265,12 +3423,19 @@ 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.", + 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 tokensByEnvKey = Object.fromEntries( messagingTokenDefs.map(({ envKey, token }) => [envKey, token]), @@ -4784,7 +4949,10 @@ function getSuggestedPolicyPresets({ enabledChannels = null, webSearchConfig = n maybeSuggestMessagingPreset("slack", "SLACK_BOT_TOKEN"); maybeSuggestMessagingPreset("discord", "DISCORD_BOT_TOKEN"); - if (webSearchConfig) suggestions.push("brave"); + const normalizedWebSearchConfig = normalizeWebSearchConfigValue(webSearchConfig); + if (normalizedWebSearchConfig) { + suggestions.push(getWebSearchProviderMetadata(normalizedWebSearchConfig.provider).policyPreset); + } return suggestions; } @@ -5377,7 +5545,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); const enabledChannels = Array.isArray(options.enabledChannels) ? options.enabledChannels : null; const provider = options.provider || null; @@ -5408,7 +5576,10 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { // Seed suggestions from the tier's default preset names (for non-interactive path). const suggestions = tiers.resolveTierPresets(tierName).map((p) => p.name); // Allow credential-based overrides on top of the tier (additive only). - if (webSearchConfig && !suggestions.includes("brave")) suggestions.push("brave"); + if (webSearchConfig) { + const webSearchPreset = getWebSearchProviderMetadata(webSearchConfig.provider).policyPreset; + if (!suggestions.includes(webSearchPreset)) suggestions.push(webSearchPreset); + } // Auto-suggest local-inference preset when a local provider is selected if (provider && LOCAL_INFERENCE_PROVIDERS.includes(provider) && !suggestions.includes("local-inference")) { suggestions.push("local-inference"); @@ -6104,6 +6275,41 @@ async function onboard(opts = {}) { break; } + 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 ${label} configuration.`); + } else { + webSearchConfig = await configureWebSearch(null); + onboardSession.updateSession((current) => { + current.webSearchConfig = webSearchConfig; + return current; + }); + } + } else { + webSearchConfig = await configureWebSearch(webSearchConfig); + onboardSession.updateSession((current) => { + current.webSearchConfig = webSearchConfig; + return current; + }); + } + const sandboxReuseState = getSandboxReuseState(sandboxName); const webSearchConfigChanged = Boolean(session?.webSearchConfig) !== Boolean(webSearchConfig); const resumeSandbox = @@ -6112,9 +6318,6 @@ async function onboard(opts = {}) { session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready"; if (resumeSandbox) { - if (webSearchConfig) { - note(" [resume] Reusing Brave Search configuration already baked into the sandbox."); - } skippedStepMessage("sandbox", sandboxName); } else { if (resume && session?.steps?.sandbox?.status === "complete") { @@ -6135,17 +6338,6 @@ async function onboard(opts = {}) { } } } - let nextWebSearchConfig = webSearchConfig; - if (nextWebSearchConfig) { - note(" [resume] Revalidating Brave Search configuration for sandbox recreation."); - const braveApiKey = await ensureValidatedBraveSearchCredential(); - nextWebSearchConfig = braveApiKey ? { fetchEnabled: true } : null; - if (nextWebSearchConfig) { - note(" [resume] Reusing Brave Search configuration."); - } - } else { - nextWebSearchConfig = await configureWebSearch(null); - } startRecordedStep("sandbox", { sandboxName, provider, model }); selectedMessagingChannels = await setupMessagingChannels(); onboardSession.updateSession((current) => { @@ -6158,13 +6350,12 @@ async function onboard(opts = {}) { provider, preferredInferenceApi, sandboxName, - nextWebSearchConfig, + webSearchConfig, selectedMessagingChannels, fromDockerfile, agent, dangerouslySkipPermissions, ); - webSearchConfig = nextWebSearchConfig; // Persist model and provider after the sandbox entry exists in the registry. // updateSandbox() silently no-ops when the entry is missing, so this must // run after createSandbox() / registerSandbox() — not before. Fixes #1881. @@ -6278,7 +6469,7 @@ module.exports = { classifySandboxCreateFailure, configureWebSearch, createSandbox, - ensureValidatedBraveSearchCredential, + ensureValidatedWebSearchCredential, formatEnvAssignment, getFutureShellPathHint, getGatewayStartEnv, diff --git a/src/lib/web-search.test.ts b/src/lib/web-search.test.ts index cf98d55d781..8cbea1b7969 100644 --- a/src/lib/web-search.test.ts +++ b/src/lib/web-search.test.ts @@ -3,10 +3,137 @@ import { describe, expect, it } from "vitest"; -import { BRAVE_API_KEY_ENV } from "./web-search"; +import { + buildWebSearchConfigFragment, + buildWebSearchDockerConfig, + DEFAULT_GEMINI_WEB_SEARCH_MODEL, + getWebSearchExposureWarningLines, + normalizeWebSearchConfig, +} from "./web-search"; -describe("web-search module", () => { - it("exports BRAVE_API_KEY_ENV constant", () => { - expect(BRAVE_API_KEY_ENV).toBe("BRAVE_API_KEY"); +describe("web-search helpers", () => { + it("emits empty docker config when web search is disabled", () => { + expect(Buffer.from(buildWebSearchDockerConfig(null, null), "base64").toString("utf8")).toBe( + "{}", + ); + }); + + it("emits empty docker config when fetchEnabled is false", () => { + expect( + Buffer.from( + buildWebSearchDockerConfig({ provider: "brave", fetchEnabled: false }, null), + "base64", + ).toString("utf8"), + ).toBe("{}"); + }); + + it("normalizes legacy Brave configs without an explicit provider", () => { + expect(normalizeWebSearchConfig({ fetchEnabled: true })).toEqual({ + provider: "brave", + fetchEnabled: true, + }); + }); + + 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: "openshell:resolve:env:BRAVE_API_KEY", + }, + }, + }, + }, + }, + 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: "openshell:resolve:env:GEMINI_API_KEY", + }, + }, + }, + }, + }, + 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: "openshell:resolve:env:TAVILY_API_KEY", + }, + }, + }, + }, + }, + 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 resolve and read"); }); }); diff --git a/src/lib/web-search.ts b/src/lib/web-search.ts index dd6d7682ac9..d969f23a649 100644 --- a/src/lib/web-search.ts +++ b/src/lib/web-search.ts @@ -1,8 +1,166 @@ // 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"); +} + +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 a ${label} API key resolver in sandbox OpenClaw config.`, + "The OpenClaw agent will be able to resolve and read that key at runtime.", + ]; +} + +export function buildWebSearchConfigFragment( + config: WebSearchConfig | null, + apiKey: string | null, +): Record { + const normalized = normalizeWebSearchConfig(config); + if (!normalized) return {}; + + const { credentialEnv, pluginEntry } = getWebSearchProvider(normalized.provider); + const apiKeyRef = apiKey ? `openshell:resolve:env:${credentialEnv}` : null; + return { + plugins: { + entries: { + [pluginEntry]: { + enabled: true, + config: { + webSearch: { + ...(normalized.provider === "gemini" + ? { model: DEFAULT_GEMINI_WEB_SEARCH_MODEL } + : {}), + ...(apiKeyRef ? { apiKey: apiKeyRef } : {}), + }, + }, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: normalized.provider, + }, + fetch: { + enabled: true, + }, + }, + }, + }; +} + +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 f89017fb550..caf6263000f 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. .openclaw directory not writable by sandbox (no symlink replacement)" +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 "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,14 +110,22 @@ else fail "sandbox replaced symlink — hooks now points to: $TARGET" fi -# ── Test 4: Config hash file exists and is valid ───────────────── +# ── Test 5: gateway user can write the live config as well ─────── -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" +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" + fail "gateway could not update the live config: $OUT" fi # ── Test 5: Update hints are disabled in sandbox config ────────── @@ -112,16 +138,6 @@ else fail "startup update hints not disabled: $OUT" fi -# ── Test 6: Config hash is not writable by sandbox ─────────────── - -info "6. 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" -fi - # ── Test 7: gosu is installed ──────────────────────────────────── info "7. gosu binary is available" @@ -169,6 +185,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 11: iptables is installed (required for network policy enforcement) ── info "11. iptables is installed" diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index a7fa3443bcd..5f189830ebd 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -17,24 +17,49 @@ describe("nemoclaw-start non-root fallback", () => { expect(src).toMatch(/nohup "\$OPENCLAW" gateway run --port "\$\{_DASHBOARD_PORT\}" >\/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("sends startup diagnostics to stderr so they do not leak into bridge output (#1064)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); @@ -251,26 +276,26 @@ describe("runtime model override (#759)", () => { expect(src).toContain("NEMOCLAW_MODEL_OVERRIDE"); }); - it("calls apply_model_override after verify_config_integrity in both paths", () => { + it("calls apply_model_override after verify_config_layout in both paths", () => { // Non-root path: extract from uid check to the Root path comment const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/); expect(nonRootBlock).toBeTruthy(); expect(nonRootBlock[1]).toMatch( - /verify_config_integrity[\s\S]*?apply_model_override[\s\S]*?export_gateway_token/, + /verify_config_layout[\s\S]*?apply_model_override[\s\S]*?export_gateway_token/, ); - // Root path: verify_config_integrity → apply_model_override → apply_cors_override + // Root path: verify_config_layout -> apply_model_override -> apply_cors_override const rootBlock = src.match( - /# ── Root path[\s\S]*?verify_config_integrity[\s\S]*?apply_model_override[\s\S]*?apply_cors_override[\s\S]*?export_gateway_token/, + /# ── Root path[\s\S]*?verify_config_layout[\s\S]*?apply_model_override[\s\S]*?apply_cors_override[\s\S]*?export_gateway_token/, ); expect(rootBlock).toBeTruthy(); }); - it("recomputes config hash after override", () => { + it("writes the live OpenClaw config after override", () => { const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); expect(fn).toBeTruthy(); - expect(fn[1]).toContain("sha256sum openclaw.json"); - expect(fn[1]).toContain("config-hash"); + expect(fn[1]).toContain('local config_file="$OPENCLAW_CONFIG_PATH"'); + expect(fn[1]).toContain("Model override written"); }); it("is a no-op when no override env vars are set", () => { @@ -285,11 +310,10 @@ describe("runtime model override (#759)", () => { expect(src).toContain("NEMOCLAW_INFERENCE_API_OVERRIDE"); }); - it("guards against symlink attacks on config and hash files", () => { + it("refuses model override when the live config file is missing", () => { const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); expect(fn).toBeTruthy(); - expect(fn[1]).toContain('-L "$config_file"'); - expect(fn[1]).toContain('-L "$hash_file"'); + expect(fn[1]).toContain('[ ! -f "$config_file" ]'); expect(fn[1]).toContain("Refusing model override"); }); @@ -386,11 +410,11 @@ describe("runtime CORS origin override (#719)", () => { expect(rootBlock).toBeTruthy(); }); - it("recomputes config hash after override", () => { + it("writes the live OpenClaw config after override", () => { const fn = src.match(/apply_cors_override\(\) \{([\s\S]*?)^}/m); expect(fn).toBeTruthy(); - expect(fn[1]).toContain("sha256sum openclaw.json"); - expect(fn[1]).toContain("config-hash"); + expect(fn[1]).toContain('local config_file="$OPENCLAW_CONFIG_PATH"'); + expect(fn[1]).toContain("CORS override written"); }); it("is a no-op when NEMOCLAW_CORS_ORIGIN is not set", () => { @@ -405,10 +429,10 @@ describe("runtime CORS origin override (#719)", () => { expect(fn[1]).toContain("^https?://"); }); - it("guards against symlink attacks", () => { + it("refuses CORS override when the live config file is missing", () => { const fn = src.match(/apply_cors_override\(\) \{([\s\S]*?)^}/m); expect(fn).toBeTruthy(); - expect(fn[1]).toContain('-L "$config_file"'); + expect(fn[1]).toContain('[ ! -f "$config_file" ]'); expect(fn[1]).toContain("Refusing CORS override"); }); @@ -463,11 +487,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.ts b/test/onboard.test.ts index c467e2fe793..227c7859bc8 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -172,7 +172,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -210,7 +210,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_MAX_TOKENS=4096", "ARG NEMOCLAW_REASONING=false", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -250,7 +250,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", @@ -308,7 +308,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", @@ -540,7 +540,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -577,7 +577,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", "ARG NEMOCLAW_PROXY_HOST=10.200.0.1", "ARG NEMOCLAW_PROXY_PORT=3128", @@ -629,7 +629,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", "ARG NEMOCLAW_PROXY_HOST=10.200.0.1", "ARG NEMOCLAW_PROXY_PORT=3128", @@ -672,7 +672,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", "ARG NEMOCLAW_PROXY_HOST=10.200.0.1", "ARG NEMOCLAW_PROXY_PORT=3128", @@ -724,7 +724,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -739,12 +739,20 @@ describe("onboard helpers", () => { "build-web", "openai-api", null, - { fetchEnabled: true }, + { provider: "brave", fetchEnabled: true }, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); - assert.match(patched, /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=1$/m); - // Regression guard: the old secret-bearing build arg must not reappear. - assert.doesNotMatch(patched, /NEMOCLAW_WEB_CONFIG_B64/); + 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; @@ -755,6 +763,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", @@ -2124,7 +2184,45 @@ const { setupInference } = require(${onboardPath}); assert.match( source, - /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*selectedMessagingChannels = await setupMessagingChannels\(\);\s*onboardSession\.updateSession\(\(current\) => \{\s*current\.messagingChannels = selectedMessagingChannels;\s*return current;\s*\}\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*nextWebSearchConfig,\s*selectedMessagingChannels,\s*fromDockerfile,\s*agent,\s*dangerouslySkipPermissions,\s*\);/, + /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*selectedMessagingChannels = await setupMessagingChannels\(\);\s*onboardSession\.updateSession\(\(current\) => \{\s*current\.messagingChannels = selectedMessagingChannels;\s*return current;\s*\}\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*webSearchConfig,\s*selectedMessagingChannels,\s*fromDockerfile,\s*agent,\s*dangerouslySkipPermissions,\s*\);/, + ); + }); + + it("keeps the current web-search key on transport retries", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), + "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, "..", "src", "lib", "onboard.ts"), + "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, ); }); @@ -5240,7 +5338,7 @@ const { createSandbox } = require(${onboardPath}); "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -5282,7 +5380,7 @@ const { createSandbox } = require(${onboardPath}); "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -5323,7 +5421,7 @@ const { createSandbox } = require(${onboardPath}); "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -5370,7 +5468,7 @@ const { createSandbox } = require(${onboardPath}); "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -5420,7 +5518,7 @@ const { createSandbox } = require(${onboardPath}); "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); diff --git a/test/openclaw-config-layout.test.ts b/test/openclaw-config-layout.test.ts new file mode 100644 index 00000000000..143a600a773 --- /dev/null +++ b/test/openclaw-config-layout.test.ts @@ -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.ts b/test/policies.test.ts index fac2571f5af..bf98803f8ed 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -96,9 +96,9 @@ selectFromList(items, options) describe("policies", () => { describe("listPresets", () => { - it("returns all 12 presets", () => { + it("returns all 14 presets", () => { const presets = policies.listPresets(); - expect(presets.length).toBe(12); + expect(presets.length).toBe(14); }); it("each preset has name and description", () => { @@ -117,6 +117,7 @@ describe("policies", () => { "brave", "brew", "discord", + "gemini", "github", "huggingface", "jira", @@ -125,6 +126,7 @@ describe("policies", () => { "outlook", "pypi", "slack", + "tavily", "telegram", ]; expect(names).toEqual(expected); @@ -187,6 +189,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);