diff --git a/Dockerfile b/Dockerfile index 2f7660554c2..3a446291f08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -192,6 +192,7 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ # Copy startup script and shared sandbox initialisation library COPY scripts/lib/sandbox-init.sh /usr/local/lib/nemoclaw/sandbox-init.sh COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start +COPY scripts/generate-openclaw-config.py /usr/local/lib/nemoclaw/generate-openclaw-config.py RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init.sh # Build args for config that varies per deployment. @@ -228,8 +229,10 @@ ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30= # (e.g. {"1234567890":{"requireMention":true,"users":["555"]}}). # Used to enable guild-channel responses for native Discord. Default: empty map. ARG NEMOCLAW_DISCORD_GUILDS_B64=e30= -# Set to "1" to disable device-pairing auth (development/headless only). -# Default: "0" (device auth enabled — secure by default). +# Set to "1" to force-disable device-pairing auth. Also auto-disabled when +# CHAT_UI_URL is a non-loopback address (Brev Launchable, remote deployments) +# since terminal-based pairing is impossible in those contexts. +# Default: "0" (device auth enabled for local deployments — secure by default). ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0 # Unique per build — busts the Docker cache for the token-injection layer # so each image gets a fresh gateway auth token. @@ -288,75 +291,10 @@ USER sandbox # the OpenShell proxy. Mirror of the Telegram treatment immediately below. # Remove once OpenClaw lands an env-var-honouring fix for the Discord # gateway equivalent to openclaw/openclaw#62878 (Slack Socket Mode). -RUN python3 -c "\ -import base64, json, os; \ -from urllib.parse import urlparse; \ -proxy_url = f\"http://{os.environ['NEMOCLAW_PROXY_HOST']}:{os.environ['NEMOCLAW_PROXY_PORT']}\"; \ -model = os.environ['NEMOCLAW_MODEL']; \ -chat_ui_url = os.environ['CHAT_UI_URL']; \ -provider_key = os.environ['NEMOCLAW_PROVIDER_KEY']; \ -primary_model_ref = os.environ['NEMOCLAW_PRIMARY_MODEL_REF']; \ -inference_base_url = os.environ['NEMOCLAW_INFERENCE_BASE_URL']; \ -inference_api = os.environ['NEMOCLAW_INFERENCE_API']; \ -context_window = int(os.environ.get('NEMOCLAW_CONTEXT_WINDOW', '131072')); \ -max_tokens = int(os.environ.get('NEMOCLAW_MAX_TOKENS', '4096')); \ -reasoning = os.environ.get('NEMOCLAW_REASONING', 'false') == 'true'; \ -inference_inputs = [v.strip() for v in os.environ.get('NEMOCLAW_INFERENCE_INPUTS', 'text').split(',') if v.strip()] or ['text']; \ -_raw_agent_timeout = os.environ.get('NEMOCLAW_AGENT_TIMEOUT', '600'); \ -agent_timeout = int(_raw_agent_timeout) if _raw_agent_timeout.isdigit() and int(_raw_agent_timeout) > 0 else (_ for _ in ()).throw(ValueError('NEMOCLAW_AGENT_TIMEOUT must be a positive integer')); \ -inference_compat = json.loads(base64.b64decode(os.environ['NEMOCLAW_INFERENCE_COMPAT_B64']).decode('utf-8')); \ -msg_channels = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_CHANNELS_B64', 'W10=') or 'W10=').decode('utf-8')); \ -_allowed_ids = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_ALLOWED_IDS_B64', 'e30=') or 'e30=').decode('utf-8')); \ -_discord_guilds = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_DISCORD_GUILDS_B64', 'e30=') or 'e30=').decode('utf-8')); \ -_token_keys = {'discord': 'token', 'telegram': 'botToken', 'slack': 'botToken'}; \ -_env_keys = {'discord': 'DISCORD_BOT_TOKEN', 'telegram': 'TELEGRAM_BOT_TOKEN', 'slack': 'SLACK_BOT_TOKEN'}; \ -_ch_cfg = {ch: {'accounts': {'default': {_token_keys[ch]: f'openshell:resolve:env:{_env_keys[ch]}', 'enabled': True, 'healthMonitor': {'enabled': False}, **({'appToken': 'openshell:resolve:env:SLACK_APP_TOKEN'} if ch == 'slack' else {}), **({'proxy': proxy_url} if ch in ('telegram', 'discord') else {}), **({'groupPolicy': 'open'} if ch == 'telegram' else {}), **({'dmPolicy': 'allowlist', 'allowFrom': _allowed_ids[ch]} if ch in _allowed_ids and _allowed_ids[ch] else {})}}} for ch in msg_channels if ch in _token_keys}; \ -_ch_cfg['discord'].update({'groupPolicy': 'allowlist', 'guilds': _discord_guilds}) if 'discord' in _ch_cfg and _discord_guilds else None; \ -parsed = urlparse(chat_ui_url); \ -chat_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else 'http://127.0.0.1:18789'; \ -origins = ['http://127.0.0.1:18789']; \ -origins = list(dict.fromkeys(origins + [chat_origin])); \ -disable_device_auth = os.environ.get('NEMOCLAW_DISABLE_DEVICE_AUTH', '') == '1'; \ -allow_insecure = parsed.scheme == 'http'; \ -providers = { \ - provider_key: { \ - 'baseUrl': inference_base_url, \ - 'apiKey': 'unused', \ - 'api': inference_api, \ - 'models': [{**({'compat': inference_compat} if inference_compat else {}), 'id': model, 'name': primary_model_ref, 'reasoning': reasoning, 'input': inference_inputs, 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': context_window, 'maxTokens': max_tokens}] \ - } \ -}; \ -config = { \ - 'agents': {'defaults': {'model': {'primary': primary_model_ref}, 'timeoutSeconds': agent_timeout}}, \ - 'models': {'mode': 'merge', 'providers': providers}, \ - 'channels': {'defaults': {}, **_ch_cfg}, \ - 'update': {'checkOnStart': False}, \ - 'gateway': { \ - 'mode': 'local', \ - 'controlUi': { \ - 'allowInsecureAuth': allow_insecure, \ - 'dangerouslyDisableDeviceAuth': disable_device_auth, \ - 'allowedOrigins': origins, \ - }, \ - 'trustedProxies': ['127.0.0.1', '::1'], \ - 'auth': {'token': ''} \ - } \ -}; \ -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)" +# Generate openclaw.json from environment variables. Config generation logic +# lives in scripts/generate-openclaw-config.py — see that file for the full +# list of env vars and derivation rules. +RUN python3 /usr/local/lib/nemoclaw/generate-openclaw-config.py # Install NemoClaw plugin into OpenClaw RUN openclaw doctor --fix > /dev/null 2>&1 || true \ diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py new file mode 100755 index 00000000000..e0a78a8a1c9 --- /dev/null +++ b/scripts/generate-openclaw-config.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Generate openclaw.json from environment variables. + +Called at Docker image build time (RUN layer) after ARG→ENV promotion. +Reads all configuration from os.environ — never from string interpolation +in Dockerfile source. See: C-2 security model. + +Usage: + python3 scripts/generate-openclaw-config.py # Generate config + +Environment variables: + CHAT_UI_URL Dashboard URL (default: http://127.0.0.1:18789) + NEMOCLAW_MODEL Model identifier + NEMOCLAW_PROVIDER_KEY Provider key for model config + NEMOCLAW_PRIMARY_MODEL_REF Primary model reference + NEMOCLAW_INFERENCE_BASE_URL Inference endpoint + NEMOCLAW_INFERENCE_API Inference API type + NEMOCLAW_INFERENCE_INPUTS Comma-separated model inputs (default: text) + NEMOCLAW_CONTEXT_WINDOW Context window size (default: 131072) + NEMOCLAW_MAX_TOKENS Max tokens (default: 4096) + NEMOCLAW_REASONING Enable reasoning (default: false) + NEMOCLAW_AGENT_TIMEOUT Per-request timeout seconds (default: 600) + NEMOCLAW_INFERENCE_COMPAT_B64 Base64-encoded inference compat JSON + NEMOCLAW_MESSAGING_CHANNELS_B64 Base64-encoded channel list + NEMOCLAW_MESSAGING_ALLOWED_IDS_B64 Base64-encoded allowed IDs map + NEMOCLAW_DISCORD_GUILDS_B64 Base64-encoded Discord guild config + NEMOCLAW_DISABLE_DEVICE_AUTH Set to "1" to force-disable device auth + NEMOCLAW_PROXY_HOST Egress proxy host (default: 10.200.0.1) + NEMOCLAW_PROXY_PORT Egress proxy port (default: 3128) + NEMOCLAW_WEB_SEARCH_ENABLED Set to "1" to enable web search tools +""" + +from __future__ import annotations + +import base64 +import json +import os +import re +from urllib.parse import urlparse + + +def is_loopback(hostname: str) -> bool: + """Check if a hostname is a loopback address. + + Mirrors isLoopbackHostname() from src/lib/url-utils.ts. + Returns True for localhost, ::1, and 127.x.x.x addresses. + """ + normalized = (hostname or "").strip().lower().strip("[]") + if normalized == "localhost" or normalized == "::1": + return True + return bool(re.match(r"^127(?:\.\d{1,3}){3}$", normalized)) + + +def build_config(env: dict | None = None) -> dict: + """Build the complete openclaw config dict from environment variables. + + Args: + env: Dict of environment variables. Defaults to os.environ. + + Returns: + Complete config dict ready to be written as JSON. + """ + if env is None: + env = dict(os.environ) + + # Treat empty-string env vars as unset so the documented defaults still + # apply when callers pass an explicit "" (e.g. `docker build --build-arg + # CHAT_UI_URL=`). + proxy_host = env.get("NEMOCLAW_PROXY_HOST") or "10.200.0.1" + proxy_port = env.get("NEMOCLAW_PROXY_PORT") or "3128" + proxy_url = f"http://{proxy_host}:{proxy_port}" + model = env["NEMOCLAW_MODEL"] + chat_ui_url = env.get("CHAT_UI_URL") or "http://127.0.0.1:18789" + provider_key = env["NEMOCLAW_PROVIDER_KEY"] + primary_model_ref = env["NEMOCLAW_PRIMARY_MODEL_REF"] + inference_base_url = env["NEMOCLAW_INFERENCE_BASE_URL"] + inference_api = env["NEMOCLAW_INFERENCE_API"] + context_window = int(env.get("NEMOCLAW_CONTEXT_WINDOW", "131072")) + max_tokens = int(env.get("NEMOCLAW_MAX_TOKENS", "4096")) + reasoning = env.get("NEMOCLAW_REASONING", "false") == "true" + inference_inputs = [ + v.strip() + for v in env.get("NEMOCLAW_INFERENCE_INPUTS", "text").split(",") + if v.strip() + ] or ["text"] + + _raw_agent_timeout = env.get("NEMOCLAW_AGENT_TIMEOUT", "600") + if not _raw_agent_timeout.isdigit() or int(_raw_agent_timeout) <= 0: + raise ValueError("NEMOCLAW_AGENT_TIMEOUT must be a positive integer") + agent_timeout = int(_raw_agent_timeout) + + inference_compat = json.loads( + base64.b64decode(env["NEMOCLAW_INFERENCE_COMPAT_B64"]).decode("utf-8") + ) + + msg_channels = json.loads( + base64.b64decode( + env.get("NEMOCLAW_MESSAGING_CHANNELS_B64", "W10=") or "W10=" + ).decode("utf-8") + ) + _allowed_ids = json.loads( + base64.b64decode( + env.get("NEMOCLAW_MESSAGING_ALLOWED_IDS_B64", "e30=") or "e30=" + ).decode("utf-8") + ) + _discord_guilds = json.loads( + base64.b64decode( + env.get("NEMOCLAW_DISCORD_GUILDS_B64", "e30=") or "e30=" + ).decode("utf-8") + ) + + _token_keys = {"discord": "token", "telegram": "botToken", "slack": "botToken"} + _env_keys = { + "discord": "DISCORD_BOT_TOKEN", + "telegram": "TELEGRAM_BOT_TOKEN", + "slack": "SLACK_BOT_TOKEN", + } + + _ch_cfg = {} + for ch in msg_channels: + if ch not in _token_keys: + continue + account = { + _token_keys[ch]: f"openshell:resolve:env:{_env_keys[ch]}", + "enabled": True, + "healthMonitor": {"enabled": False}, + } + if ch == "slack": + account["appToken"] = "openshell:resolve:env:SLACK_APP_TOKEN" + if ch in ("telegram", "discord"): + account["proxy"] = proxy_url + if ch == "telegram": + account["groupPolicy"] = "open" + if ch in _allowed_ids and _allowed_ids[ch]: + account["dmPolicy"] = "allowlist" + account["allowFrom"] = _allowed_ids[ch] + _ch_cfg[ch] = {"accounts": {"default": account}} + + if "discord" in _ch_cfg and _discord_guilds: + _ch_cfg["discord"].update( + {"groupPolicy": "allowlist", "guilds": _discord_guilds} + ) + + # Normalize schemeless URLs before parsing — urlparse("remote-host:18789") + # misclassifies hostname as scheme. Mirrors ensureScheme() in dashboard-contract.ts. + _normalized_url = chat_ui_url + if chat_ui_url and not re.match(r"^[a-z][a-z0-9+.-]*://", chat_ui_url, re.IGNORECASE): + _normalized_url = f"http://{chat_ui_url}" + + parsed = urlparse(_normalized_url) + chat_origin = ( + f"{parsed.scheme}://{parsed.netloc}" + if parsed.scheme and parsed.netloc + else "http://127.0.0.1:18789" + ) + origins = list(dict.fromkeys(["http://127.0.0.1:18789", chat_origin])) + + # Auto-disable device auth when CHAT_UI_URL is non-loopback — terminal-based + # pairing is impossible when the user only has web access (Brev Launchable, + # remote deployments). The explicit env var override still works but cannot + # re-enable device auth for non-loopback URLs (security default). + _is_remote = not is_loopback(parsed.hostname or "") + disable_device_auth = ( + env.get("NEMOCLAW_DISABLE_DEVICE_AUTH", "") == "1" + or _is_remote + ) + allow_insecure = parsed.scheme == "http" + + providers = { + provider_key: { + "baseUrl": inference_base_url, + "apiKey": "unused", + "api": inference_api, + "models": [ + { + **({"compat": inference_compat} if inference_compat else {}), + "id": model, + "name": primary_model_ref, + "reasoning": reasoning, + "input": inference_inputs, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + }, + "contextWindow": context_window, + "maxTokens": max_tokens, + } + ], + } + } + + config = { + "agents": { + "defaults": { + "model": {"primary": primary_model_ref}, + "timeoutSeconds": agent_timeout, + } + }, + "models": {"mode": "merge", "providers": providers}, + "channels": {"defaults": {}, **_ch_cfg}, + "update": {"checkOnStart": False}, + "gateway": { + "mode": "local", + "controlUi": { + "allowInsecureAuth": allow_insecure, + "dangerouslyDisableDeviceAuth": disable_device_auth, + "allowedOrigins": origins, + }, + "trustedProxies": ["127.0.0.1", "::1"], + "auth": {"token": ""}, + }, + } + + if env.get("NEMOCLAW_WEB_SEARCH_ENABLED", "") == "1": + config["tools"] = { + "web": { + "search": { + "enabled": True, + "provider": "brave", + "apiKey": "openshell:resolve:env:BRAVE_API_KEY", + }, + "fetch": {"enabled": True}, + } + } + + return config + + +def main() -> None: + """Generate openclaw.json from environment variables.""" + config = build_config() + path = os.path.expanduser("~/.openclaw/openclaw.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(config, f, indent=2) + os.chmod(path, 0o600) + + +if __name__ == "__main__": + main() diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 466c465ada1..9f5e53d03ef 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -12,7 +12,8 @@ # Optional env: # NVIDIA_API_KEY API key for NVIDIA-hosted inference # CHAT_UI_URL Browser origin that will access the forwarded dashboard -# NEMOCLAW_DISABLE_DEVICE_AUTH Build-time only. Set to "1" to skip device-pairing auth +# NEMOCLAW_DISABLE_DEVICE_AUTH Build-time only. Set to "1" to skip device-pairing auth. +# Also auto-disabled when CHAT_UI_URL is non-loopback. # (development/headless). Has no runtime effect — openclaw.json # is baked at image build and verified by hash at startup. # NEMOCLAW_MODEL_OVERRIDE Override the primary model at startup without rebuilding diff --git a/src/lib/dashboard-contract.test.ts b/src/lib/dashboard-contract.test.ts index 997797f2eb8..a82f5917c17 100644 --- a/src/lib/dashboard-contract.test.ts +++ b/src/lib/dashboard-contract.test.ts @@ -12,6 +12,7 @@ describe("buildChain", () => { healthEndpoint: "/health", port: 18789, bindAddress: "127.0.0.1", }); expect(c.corsOrigins).toEqual(["http://127.0.0.1:18789"]); + expect(c.shouldDisableDeviceAuth).toBe(false); }); it("preserves custom port from loopback URL", () => { @@ -26,6 +27,7 @@ describe("buildChain", () => { expect(c.bindAddress).toBe("0.0.0.0"); expect(c.corsOrigins[0]).toBe("http://127.0.0.1:18789"); expect(c.corsOrigins).toContain("https://my-brev-host.example.com:18789"); + expect(c.shouldDisableDeviceAuth).toBe(true); }); it("uses WSL host address and binds to 0.0.0.0", () => { @@ -33,6 +35,7 @@ describe("buildChain", () => { expect(c.forwardTarget).toBe("0.0.0.0:18789"); expect(c.accessUrl).toBe("http://172.24.240.1:18789"); expect(c.corsOrigins).toContain("http://172.24.240.1:18789"); + expect(c.shouldDisableDeviceAuth).toBe(true); }); it("respects explicit port override", () => { @@ -53,6 +56,15 @@ describe("buildChain", () => { const c = buildChain({ chatUiUrl: "remote-host:18789" }); expect(c.accessUrl).toBe("http://remote-host:18789"); expect(c.forwardTarget).toBe("0.0.0.0:18789"); + expect(c.shouldDisableDeviceAuth).toBe(true); + }); + + it("shouldDisableDeviceAuth is false for localhost", () => { + expect(buildChain({ chatUiUrl: "http://localhost:18789" }).shouldDisableDeviceAuth).toBe(false); + }); + + it("shouldDisableDeviceAuth is false for IPv6 loopback", () => { + expect(buildChain({ chatUiUrl: "http://[::1]:18789" }).shouldDisableDeviceAuth).toBe(false); }); }); diff --git a/src/lib/dashboard-contract.ts b/src/lib/dashboard-contract.ts index d5a59270397..ea387a5b14c 100644 --- a/src/lib/dashboard-contract.ts +++ b/src/lib/dashboard-contract.ts @@ -23,6 +23,7 @@ export interface DashboardDeliveryChain { healthEndpoint: string; port: number; bindAddress: string; + shouldDisableDeviceAuth: boolean; } function ensureScheme(raw: string): string { @@ -75,7 +76,9 @@ export function buildChain(hints?: PlatformHints): DashboardDeliveryChain { const corsOrigins = accessOrigin && accessOrigin !== loopbackOrigin ? [loopbackOrigin, accessOrigin] : [loopbackOrigin]; - return { accessUrl, corsOrigins, forwardTarget, healthEndpoint: "/health", port, bindAddress }; + const shouldDisableDeviceAuth = hasNonLoopbackUrl || (h.isWsl ?? false); + + return { accessUrl, corsOrigins, forwardTarget, healthEndpoint: "/health", port, bindAddress, shouldDisableDeviceAuth }; } /** Build the list of control UI URLs. Callers pass chatUiUrl explicitly. */ diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts new file mode 100644 index 00000000000..962386e8c74 --- /dev/null +++ b/test/generate-openclaw-config.test.ts @@ -0,0 +1,231 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Functional tests for scripts/generate-openclaw-config.py. +// Runs the actual Python script with controlled env vars and asserts on +// the generated openclaw.json output. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.py"); + +/** Minimal env vars required for a valid config generation run. */ +const BASE_ENV: Record = { + NEMOCLAW_MODEL: "test-model", + NEMOCLAW_PROVIDER_KEY: "test-provider", + NEMOCLAW_PRIMARY_MODEL_REF: "test-ref", + CHAT_UI_URL: "http://127.0.0.1:18789", + NEMOCLAW_INFERENCE_BASE_URL: "http://localhost:8080", + NEMOCLAW_INFERENCE_API: "openai", + NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from("{}").toString("base64"), + NEMOCLAW_PROXY_HOST: "10.200.0.1", + NEMOCLAW_PROXY_PORT: "3128", + NEMOCLAW_CONTEXT_WINDOW: "131072", + NEMOCLAW_MAX_TOKENS: "4096", + NEMOCLAW_REASONING: "false", + NEMOCLAW_AGENT_TIMEOUT: "600", +}; + +let tmpDir: string; + +function runConfigScript(envOverrides: Record = {}): any { + const env: Record = { + PATH: process.env.PATH || "/usr/bin:/bin", + ...BASE_ENV, + ...envOverrides, + HOME: tmpDir, + }; + const result = spawnSync("python3", [SCRIPT_PATH], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env, + timeout: 10_000, + }); + + if (result.status !== 0) { + throw new Error( + `Script failed (exit ${result.status}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + } + + const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); + return JSON.parse(fs.readFileSync(configPath, "utf-8")); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-config-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Phase 1: Extraction — behavior-preserving tests +// ═══════════════════════════════════════════════════════════════════ +describe("generate-openclaw-config.py: config generation", () => { + it("generates valid JSON with minimal env vars", () => { + const config = runConfigScript(); + expect(config).toBeDefined(); + expect(config.gateway).toBeDefined(); + expect(config.models).toBeDefined(); + expect(config.agents).toBeDefined(); + }); + + it("sets dangerouslyDisableDeviceAuth to false for loopback URL", () => { + const config = runConfigScript({ CHAT_UI_URL: "http://127.0.0.1:18789" }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(false); + }); + + it("sets dangerouslyDisableDeviceAuth to true when env var is '1'", () => { + const config = runConfigScript({ NEMOCLAW_DISABLE_DEVICE_AUTH: "1" }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + }); + + it("sets allowInsecureAuth to true for http scheme", () => { + const config = runConfigScript({ CHAT_UI_URL: "http://127.0.0.1:18789" }); + expect(config.gateway.controlUi.allowInsecureAuth).toBe(true); + }); + + it("sets allowInsecureAuth to false for https scheme", () => { + const config = runConfigScript({ CHAT_UI_URL: "https://nemoclaw0-xxx.brevlab.com:18789" }); + expect(config.gateway.controlUi.allowInsecureAuth).toBe(false); + }); + + it("includes non-loopback origin in allowedOrigins", () => { + const config = runConfigScript({ + CHAT_UI_URL: "https://nemoclaw0-xxx.brevlab.com:18789", + }); + expect(config.gateway.controlUi.allowedOrigins).toContain("http://127.0.0.1:18789"); + expect(config.gateway.controlUi.allowedOrigins).toContain( + "https://nemoclaw0-xxx.brevlab.com:18789", + ); + }); + + it("includes only loopback origin for loopback URL", () => { + const config = runConfigScript({ CHAT_UI_URL: "http://127.0.0.1:18789" }); + expect(config.gateway.controlUi.allowedOrigins).toEqual(["http://127.0.0.1:18789"]); + }); + + it("parses messaging channels from base64", () => { + const channels = Buffer.from(JSON.stringify(["telegram"])).toString("base64"); + const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); + expect(config.channels).toBeDefined(); + expect(config.channels.telegram).toBeDefined(); + }); + + it("enables web search when env is '1'", () => { + const config = runConfigScript({ NEMOCLAW_WEB_SEARCH_ENABLED: "1" }); + expect(config.tools?.web?.search?.enabled).toBe(true); + }); + + it("omits web search when env is not set", () => { + const config = runConfigScript(); + expect(config.tools?.web).toBeUndefined(); + }); + + it("propagates agent timeout", () => { + const config = runConfigScript({ NEMOCLAW_AGENT_TIMEOUT: "300" }); + expect(config.agents.defaults.timeoutSeconds).toBe(300); + }); + + it("sets gateway auth token to empty string", () => { + const config = runConfigScript(); + expect(config.gateway.auth.token).toBe(""); + }); + + it("creates file with 0600 permissions", () => { + runConfigScript(); + const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); + const stats = fs.statSync(configPath); + // 0o600 = owner read/write only (octal 600 = decimal 384) + expect(stats.mode & 0o777).toBe(0o600); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Phase 2: Auto-disable device auth for non-loopback URLs +// ═══════════════════════════════════════════════════════════════════ +describe("generate-openclaw-config.py: non-loopback auto-disable device auth", () => { + it("auto-disables device auth for Brev Launchable URL", () => { + const config = runConfigScript({ + CHAT_UI_URL: "https://nemoclaw0-xxx.brevlab.com:18789", + }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + }); + + it("auto-disables device auth for any non-loopback URL", () => { + const config = runConfigScript({ + CHAT_UI_URL: "http://my-server.local:18789", + }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + }); + + it("keeps device auth enabled for 127.0.0.1", () => { + const config = runConfigScript({ CHAT_UI_URL: "http://127.0.0.1:18789" }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(false); + }); + + it("keeps device auth enabled for localhost", () => { + const config = runConfigScript({ CHAT_UI_URL: "http://localhost:18789" }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(false); + }); + + it("keeps device auth enabled for IPv6 loopback", () => { + const config = runConfigScript({ CHAT_UI_URL: "http://[::1]:18789" }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(false); + }); + + it("honors explicit env var override on loopback URL", () => { + const config = runConfigScript({ + CHAT_UI_URL: "http://127.0.0.1:18789", + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + }); + + it("URL trumps env var — cannot re-enable device auth for non-loopback", () => { + const config = runConfigScript({ + CHAT_UI_URL: "https://nemoclaw0-xxx.brevlab.com:18789", + NEMOCLAW_DISABLE_DEVICE_AUTH: "0", + }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + }); +}); + +describe("generate-openclaw-config.py: empty-string env vars fall back to defaults", () => { + it("treats empty CHAT_UI_URL as unset and uses the loopback default", () => { + const config = runConfigScript({ CHAT_UI_URL: "" }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(false); + expect(config.gateway.controlUi.allowedOrigins).toEqual([ + "http://127.0.0.1:18789", + ]); + }); + + it("treats empty NEMOCLAW_PROXY_HOST as unset and uses the documented default", () => { + const channelB64 = Buffer.from(JSON.stringify(["telegram"])).toString("base64"); + const cfg = runConfigScript({ + NEMOCLAW_PROXY_HOST: "", + NEMOCLAW_MESSAGING_CHANNELS_B64: channelB64, + }); + expect(cfg.channels.telegram.accounts.default.proxy).toBe( + "http://10.200.0.1:3128", + ); + }); + + it("treats empty NEMOCLAW_PROXY_PORT as unset and uses the documented default", () => { + const channelB64 = Buffer.from(JSON.stringify(["telegram"])).toString("base64"); + const cfg = runConfigScript({ + NEMOCLAW_PROXY_PORT: "", + NEMOCLAW_MESSAGING_CHANNELS_B64: channelB64, + }); + expect(cfg.channels.telegram.accounts.default.proxy).toBe( + "http://10.200.0.1:3128", + ); + }); +}); diff --git a/test/security-c2-dockerfile-injection.test.ts b/test/security-c2-dockerfile-injection.test.ts index 611e61fff1e..53b5d64beea 100644 --- a/test/security-c2-dockerfile-injection.test.ts +++ b/test/security-c2-dockerfile-injection.test.ts @@ -203,8 +203,12 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python if (inEnvBlock && !/\\\s*$/.test(line)) { inEnvBlock = false; } - // Verify promotion happened before the python3 -c RUN layer - if (/^\s*RUN\b.*python3\s+-c\b/.test(line)) { + // Verify promotion happened before the config-generation RUN layer + if ( + /^\s*RUN\b.*python3\s+\/usr\/local\/lib\/nemoclaw\/generate-openclaw-config\.py\b/.test( + line, + ) + ) { expect(chatUiUrlPromoted).toBeTruthy(); return; // Found the RUN layer and verified — done } @@ -212,31 +216,17 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python expect(chatUiUrlPromoted).toBeTruthy(); }); - it("Python script uses os.environ to read CHAT_UI_URL", () => { - const src = fs.readFileSync(DOCKERFILE, "utf-8"); - const lines = src.split("\n"); - let inPythonRunBlock = false; - let hasEnvRead = false; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (/^\s*RUN\b.*python3\s+-c\b/.test(line)) { - inPythonRunBlock = true; - } - if (inPythonRunBlock) { - if ( - line.includes("os.environ['CHAT_UI_URL']") || - line.includes('os.environ["CHAT_UI_URL"]') || - line.includes("os.environ.get('CHAT_UI_URL'") || - line.includes('os.environ.get("CHAT_UI_URL"') - ) { - hasEnvRead = true; - } - } - if (inPythonRunBlock && !/\\\s*$/.test(line)) { - inPythonRunBlock = false; - } - } - expect(hasEnvRead).toBeTruthy(); + it("Python config script uses os.environ to read CHAT_UI_URL", () => { + // Config generation is now in an external script, not inline python3 -c. + // Verify the Dockerfile references the script and the script reads the env var. + const dockerSrc = fs.readFileSync(DOCKERFILE, "utf-8"); + expect(dockerSrc).toMatch(/COPY.*generate-openclaw-config\.py/); + expect(dockerSrc).toMatch(/RUN python3 \/usr\/local\/lib\/nemoclaw\/generate-openclaw-config\.py/); + + const scriptPath = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.py"); + const scriptSrc = fs.readFileSync(scriptPath, "utf-8"); + expect(scriptSrc).toMatch(/CHAT_UI_URL/); + expect(scriptSrc).toMatch(/os\.environ/); }); it("Dockerfile promotes NEMOCLAW_MODEL to ENV before the RUN layer", () => { @@ -263,8 +253,12 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python if (inEnvBlock && !/\\\s*$/.test(line)) { inEnvBlock = false; } - // Verify promotion happened before the python3 -c RUN layer - if (/^\s*RUN\b.*python3\s+-c\b/.test(line)) { + // Verify promotion happened before the config-generation RUN layer + if ( + /^\s*RUN\b.*python3\s+\/usr\/local\/lib\/nemoclaw\/generate-openclaw-config\.py\b/.test( + line, + ) + ) { expect(nemoModelPromoted).toBeTruthy(); return; // Found the RUN layer and verified — done } @@ -272,31 +266,12 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python expect(nemoModelPromoted).toBeTruthy(); }); - it("Python script uses os.environ to read NEMOCLAW_MODEL", () => { - const src = fs.readFileSync(DOCKERFILE, "utf-8"); - const lines = src.split("\n"); - let inPythonRunBlock = false; - let hasEnvRead = false; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (/^\s*RUN\b.*python3\s+-c\b/.test(line)) { - inPythonRunBlock = true; - } - if (inPythonRunBlock) { - if ( - line.includes("os.environ['NEMOCLAW_MODEL']") || - line.includes('os.environ["NEMOCLAW_MODEL"]') || - line.includes("os.environ.get('NEMOCLAW_MODEL'") || - line.includes('os.environ.get("NEMOCLAW_MODEL"') - ) { - hasEnvRead = true; - } - } - if (inPythonRunBlock && !/\\\s*$/.test(line)) { - inPythonRunBlock = false; - } - } - expect(hasEnvRead).toBeTruthy(); + it("Python config script uses os.environ to read NEMOCLAW_MODEL", () => { + // Config generation is now in an external script, not inline python3 -c. + const scriptPath = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.py"); + const scriptSrc = fs.readFileSync(scriptPath, "utf-8"); + expect(scriptSrc).toMatch(/NEMOCLAW_MODEL/); + expect(scriptSrc).toMatch(/os\.environ/); }); }); @@ -316,22 +291,30 @@ describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth def expect(src).not.toMatch(/'allowInsecureAuth':\s*True/); }); - it("dangerouslyDisableDeviceAuth is derived from NEMOCLAW_DISABLE_DEVICE_AUTH env var", () => { - const src = fs.readFileSync(DOCKERFILE, "utf-8"); - // The Python config generation must read the env var - expect(src).toMatch(/os\.environ\.get\(['"]NEMOCLAW_DISABLE_DEVICE_AUTH['"]/); - // And use the derived variable in the config dict - expect(src).toMatch(/'dangerouslyDisableDeviceAuth':\s*disable_device_auth/); + it("dangerouslyDisableDeviceAuth is derived from env var AND non-loopback URL", () => { + // Config generation moved to external script — check the script source + const scriptPath = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.py"); + const src = fs.readFileSync(scriptPath, "utf-8"); + // Env var check still present + expect(src).toMatch(/NEMOCLAW_DISABLE_DEVICE_AUTH/); + // Non-loopback derivation present + expect(src).toMatch(/is_loopback/); + // Both feed into disable_device_auth + expect(src).toMatch(/disable_device_auth/); + expect(src).toMatch(/dangerouslyDisableDeviceAuth/); }); it("allowInsecureAuth is derived from URL scheme (explicit http allowlist)", () => { - const src = fs.readFileSync(DOCKERFILE, "utf-8"); + // Config generation moved to external script — check the script source + const scriptPath = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.py"); + const src = fs.readFileSync(scriptPath, "utf-8"); // Must use explicit 'http' allowlist — not `!= 'https'` which would allow // insecure auth for malformed or unknown schemes (CodeRabbit review on #123) - expect(src).toMatch(/allow_insecure\s*=\s*parsed\.scheme\s*==\s*'http'/); - expect(src).not.toMatch(/allow_insecure\s*=\s*parsed\.scheme\s*!=\s*'https'/); + expect(src).toMatch(/allow_insecure\s*=\s*parsed\.scheme\s*==\s*['"]http['"]/); + expect(src).not.toMatch(/allow_insecure\s*=\s*parsed\.scheme\s*!=\s*['"]https['"]/); // And use the derived variable in the config dict - expect(src).toMatch(/'allowInsecureAuth':\s*allow_insecure/); + expect(src).toMatch(/allowInsecureAuth/); + expect(src).toMatch(/allow_insecure/); }); it("NEMOCLAW_DISABLE_DEVICE_AUTH defaults to '0' (secure by default)", () => { @@ -359,7 +342,11 @@ describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth def if (inEnvBlock && !/\\\s*$/.test(line)) { inEnvBlock = false; } - if (/^\s*RUN\b.*python3\s+-c\b/.test(line)) { + if ( + /^\s*RUN\b.*python3\s+\/usr\/local\/lib\/nemoclaw\/generate-openclaw-config\.py\b/.test( + line, + ) + ) { expect(promoted).toBeTruthy(); return; }