From 6b4baf1d2dbff10b64c97542180f001591db9ba9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 30 May 2026 13:40:27 -0700 Subject: [PATCH 1/7] refactor(scripts): migrate OpenClaw config generator to TypeScript Signed-off-by: Carlos Villela --- Dockerfile | 14 +- scripts/generate-openclaw-config.py | 956 ----------------- scripts/generate-openclaw-config.ts | 976 ++++++++++++++++++ scripts/seed-wechat-accounts.py | 6 +- src/lib/sandbox/build-context.ts | 4 +- test/generate-openclaw-config.test.ts | 28 +- test/sandbox-build-context.test.ts | 4 +- test/sandbox-provisioning.test.ts | 4 +- test/security-c2-dockerfile-injection.test.ts | 52 +- test/seed-wechat-accounts.test.ts | 8 +- 10 files changed, 1036 insertions(+), 1016 deletions(-) delete mode 100755 scripts/generate-openclaw-config.py create mode 100755 scripts/generate-openclaw-config.ts diff --git a/Dockerfile b/Dockerfile index 1bd9054ea1..12b36ec2a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -394,12 +394,12 @@ COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start # needs to read these files to install runtime preloads under /tmp. COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp -COPY scripts/generate-openclaw-config.py /usr/local/lib/nemoclaw/generate-openclaw-config.py +COPY scripts/generate-openclaw-config.ts /usr/local/lib/nemoclaw/generate-openclaw-config.ts COPY scripts/seed-wechat-accounts.py /usr/local/lib/nemoclaw/seed-wechat-accounts.py COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ /usr/local/lib/nemoclaw/sandbox-init.sh \ - /usr/local/lib/nemoclaw/generate-openclaw-config.py \ + /usr/local/lib/nemoclaw/generate-openclaw-config.ts \ /usr/local/lib/nemoclaw/seed-wechat-accounts.py \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then find /usr/local/lib/nemoclaw/preloads -type f -name '*.js' -exec chmod 644 {} +; fi \ && chmod 755 /usr/local/share/nemoclaw \ @@ -489,9 +489,9 @@ ARG NEMOCLAW_PROXY_PORT=3128 # baked into the image. ARG NEMOCLAW_WEB_SEARCH_ENABLED=0 -# SECURITY: Promote build-args to env vars so the Python script reads them -# via os.environ, never via string interpolation into Python source code. -# Direct ARG interpolation into python3 -c is a code injection vector (C-2). +# SECURITY: Promote build-args to env vars so the TypeScript script reads them +# via process.env, never via string interpolation into executable source code. +# Direct ARG interpolation into inline source is a code injection vector (C-2). ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_PROVIDER_KEY=${NEMOCLAW_PROVIDER_KEY} \ NEMOCLAW_PRIMARY_MODEL_REF=${NEMOCLAW_PRIMARY_MODEL_REF} \ @@ -533,14 +533,14 @@ USER sandbox # Build args (NEMOCLAW_MODEL, CHAT_UI_URL) customize per deployment. # # Generate openclaw.json from environment variables. Config generation logic -# lives in scripts/generate-openclaw-config.py — see that file for the full +# lives in scripts/generate-openclaw-config.ts — see that file for the full # list of env vars and derivation rules. # # OpenClaw's managed proxy config activates process-wide HTTP_PROXY/HTTPS_PROXY # for child npm processes. During image build the OpenShell gateway is not # available at the runtime sandbox proxy address yet, so defer the final proxy # block until after build-time OpenClaw doctor/plugin commands complete. -RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 python3 /usr/local/lib/nemoclaw/generate-openclaw-config.py +RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 node --experimental-strip-types /usr/local/lib/nemoclaw/generate-openclaw-config.ts # hadolint ignore=DL3059,DL4006 RUN openclaw doctor --fix --non-interactive diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py deleted file mode 100755 index 5a58a57457..0000000000 --- a/scripts/generate-openclaw-config.py +++ /dev/null @@ -1,956 +0,0 @@ -#!/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_DASHBOARD_PORT Dashboard/gateway port (default: 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_AGENT_HEARTBEAT_EVERY OpenClaw agent heartbeat cadence (e.g. "30m", "0m" to - disable). Empty/unset preserves the OpenClaw default. - 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 (Slack IDs cover - DMs and channel @mentions) - NEMOCLAW_DISCORD_GUILDS_B64 Base64-encoded Discord guild config - NEMOCLAW_TELEGRAM_CONFIG_B64 Base64-encoded Telegram config (e.g. {"requireMention": true}) - NEMOCLAW_WECHAT_CONFIG_B64 Base64-encoded WeChat config (e.g. {"accountId": "...", "baseUrl": "...", "userId": "..."}) - NEMOCLAW_SLACK_CONFIG_B64 Base64-encoded Slack config (e.g. {"allowedChannels": ["C012AB3CD"]}) - 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_OPENCLAW_MANAGED_PROXY Set to "0" to defer OpenClaw managed proxy config - NEMOCLAW_WEB_SEARCH_ENABLED Set to "1" to enable web search tools -""" - -from __future__ import annotations - -import base64 -import json -import os -import re -import runpy -import sys -from pathlib import Path -from urllib.parse import urlparse - -KNOWN_MODEL_SETUP_AGENTS = {"openclaw", "hermes"} -MODEL_SETUP_EFFECT_KEYS = { - "openclaw": {"openclawCompat", "openclawPlugins", "openclawTools"}, - "hermes": {"hermesCompat"}, -} -DEFAULT_DASHBOARD_PORT = 18789 -MIN_DASHBOARD_PORT = 1024 -MAX_DASHBOARD_PORT = 65535 -FALSE_VALUES = {"0", "false", "no", "off"} - - -def _coerce_positive_int(env: dict, name: str, default: int) -> int: - raw = env.get(name) or str(default) - try: - value = int(raw) - except ValueError: - value = 0 - if value > 0: - return value - print( - f'[SECURITY] {name} must be a positive integer, got "{raw}" ' - f"— skipping override, falling back to default ({default})", - file=sys.stderr, - ) - return default - - -def is_loopback(hostname: str) -> bool: - """Check if a hostname is a loopback address. - - Mirrors isLoopbackHostname() from src/lib/core/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 _normalize_url_for_parse(raw_url: str) -> str: - if raw_url and not re.match(r"^[a-z][a-z0-9+.-]*://", raw_url, re.IGNORECASE): - return f"http://{raw_url}" - return raw_url - - -def _truthy_env_default(env: dict, name: str, default: bool) -> bool: - raw = env.get(name) - if raw is None or raw.strip() == "": - return default - return raw.strip().lower() not in FALSE_VALUES - - -def _validate_dashboard_port(raw: str, env_name: str) -> int: - stripped = raw.strip() - if not re.match(r"^\d+$", stripped): - raise ValueError(f"{env_name} must be an integer between 1024 and 65535") - value = int(stripped) - if value < MIN_DASHBOARD_PORT or value > MAX_DASHBOARD_PORT: - raise ValueError(f"{env_name} must be an integer between 1024 and 65535") - return value - - -def _chat_ui_url_port(chat_ui_url: str) -> int | None: - try: - port = urlparse(_normalize_url_for_parse(chat_ui_url)).port - except ValueError: - return None - if port is None: - return None - if port < MIN_DASHBOARD_PORT or port > MAX_DASHBOARD_PORT: - return None - return port - - -def _resolve_gateway_port(env: dict, chat_ui_url: str) -> int: - raw_dashboard_port = env.get("NEMOCLAW_DASHBOARD_PORT") or "" - if raw_dashboard_port.strip(): - return _validate_dashboard_port(raw_dashboard_port, "NEMOCLAW_DASHBOARD_PORT") - return _chat_ui_url_port(chat_ui_url) or DEFAULT_DASHBOARD_PORT - - -def _registry_roots(env: dict) -> list[Path]: - roots: list[Path] = [] - explicit = env.get("NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR") - if explicit: - roots.append(Path(explicit)) - - script_dir = Path(__file__).resolve().parent - roots.extend( - [ - Path("/opt/nemoclaw-blueprint/model-specific-setup"), - Path("/sandbox/.nemoclaw/blueprints/0.1.0/model-specific-setup"), - script_dir.parent / "nemoclaw-blueprint" / "model-specific-setup", - Path.cwd() / "nemoclaw-blueprint" / "model-specific-setup", - ] - ) - - unique_roots: list[Path] = [] - seen: set[str] = set() - for root in roots: - key = str(root) - if key not in seen: - unique_roots.append(root) - seen.add(key) - return unique_roots - - -def _find_registry_root(env: dict) -> Path | None: - explicit = env.get("NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR") - if explicit: - explicit_path = Path(explicit) - if not explicit_path.is_dir(): - raise ValueError( - "NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR must point to an existing directory: " - f"{explicit}" - ) - return explicit_path - - for root in _registry_roots(env): - if root.is_dir(): - return root - return None - - -def _validate_manifest_payload(payload: object, manifest_path: Path) -> dict: - if not isinstance(payload, dict): - raise ValueError(f"{manifest_path}: manifest must be a JSON object") - - setup_id = payload.get("id") - if not isinstance(setup_id, str) or not setup_id.strip(): - raise ValueError(f"{manifest_path}: field 'id' must be a non-empty string") - - agent = payload.get("agent") - if not isinstance(agent, str) or not agent.strip(): - raise ValueError(f"{manifest_path}: field 'agent' is required") - if agent not in KNOWN_MODEL_SETUP_AGENTS: - raise ValueError(f"{manifest_path}: unknown agent '{agent}'") - - description = payload.get("description") - if not isinstance(description, str) or not description.strip(): - raise ValueError(f"{manifest_path}: field 'description' must be a non-empty string") - - match = payload.get("match") - if not isinstance(match, dict): - raise ValueError(f"{manifest_path}: field 'match' must be an object") - if not match: - raise ValueError(f"{manifest_path}: field 'match' must be a non-empty object") - allowed_match_keys = {"modelIds", "providerKey", "inferenceApi", "baseUrl"} - unknown_match_keys = sorted(set(match) - allowed_match_keys) - if unknown_match_keys: - raise ValueError( - f"{manifest_path}: unknown match keys: {', '.join(unknown_match_keys)}" - ) - model_ids = match.get("modelIds") - if model_ids is not None and ( - not isinstance(model_ids, list) - or not model_ids - or not all(isinstance(model_id, str) and model_id.strip() for model_id in model_ids) - ): - raise ValueError(f"{manifest_path}: match.modelIds must be a non-empty string array") - for key in ("providerKey", "inferenceApi", "baseUrl"): - value = match.get(key) - if value is not None and (not isinstance(value, str) or not value.strip()): - raise ValueError(f"{manifest_path}: match.{key} must be a non-empty string") - - effects = payload.get("effects") - if not isinstance(effects, dict) or not effects: - raise ValueError(f"{manifest_path}: field 'effects' must be a non-empty object") - - return payload - - -def _validate_selected_agent_effects(payload: dict, manifest_path: Path, registry_root: Path) -> None: - agent = payload["agent"] - effects = payload["effects"] - allowed_effect_keys = MODEL_SETUP_EFFECT_KEYS[agent] - unknown_effect_keys = sorted(set(effects) - allowed_effect_keys) - if unknown_effect_keys: - raise ValueError( - f"{manifest_path}: unknown effects for agent '{agent}': " - f"{', '.join(unknown_effect_keys)}" - ) - - if agent == "openclaw": - compat = effects.get("openclawCompat") - if compat is not None and not isinstance(compat, dict): - raise ValueError(f"{manifest_path}: effects.openclawCompat must be an object") - - tools = effects.get("openclawTools") - if tools is not None: - if not isinstance(tools, dict): - raise ValueError(f"{manifest_path}: effects.openclawTools must be an object") - unknown_tool_keys = sorted(set(tools) - {"toolSearch"}) - if unknown_tool_keys: - raise ValueError( - f"{manifest_path}: unknown effects.openclawTools keys: " - f"{', '.join(unknown_tool_keys)}" - ) - if "toolSearch" in tools and not isinstance(tools["toolSearch"], bool): - raise ValueError( - f"{manifest_path}: effects.openclawTools.toolSearch must be a boolean" - ) - - plugins = effects.get("openclawPlugins", []) - if not isinstance(plugins, list): - raise ValueError(f"{manifest_path}: effects.openclawPlugins must be an array") - for index, plugin in enumerate(plugins): - if not isinstance(plugin, dict): - raise ValueError( - f"{manifest_path}: effects.openclawPlugins[{index}] must be an object" - ) - for key in ("id", "path", "loadPath"): - value = plugin.get(key) - if not isinstance(value, str) or not value.strip(): - raise ValueError( - f"{manifest_path}: effects.openclawPlugins[{index}].{key} " - "must be a non-empty string" - ) - source_path = Path(plugin["path"]) - if source_path.is_absolute() or ".." in source_path.parts: - raise ValueError( - f"{manifest_path}: effects.openclawPlugins[{index}].path " - "must be relative to nemoclaw-blueprint" - ) - if not (registry_root.parent / source_path).exists(): - raise ValueError( - f"{manifest_path}: effects.openclawPlugins[{index}].path does not exist: " - f"{plugin['path']}" - ) - expected_load_path = f"/usr/local/share/nemoclaw/{plugin['path'].strip('/')}" - if plugin["loadPath"].rstrip("/") != expected_load_path: - raise ValueError( - f"{manifest_path}: effects.openclawPlugins[{index}].loadPath " - f"must be '{expected_load_path}'" - ) - - if agent == "hermes": - compat = effects.get("hermesCompat") - if compat is not None and not isinstance(compat, dict): - raise ValueError(f"{manifest_path}: effects.hermesCompat must be an object") - - -def _model_setup_matches(payload: dict, context: dict) -> bool: - match = payload["match"] - model_ids = match.get("modelIds") - if model_ids and context["model"].strip().lower() not in { - model_id.strip().lower() for model_id in model_ids - }: - return False - - provider_key = match.get("providerKey") - if provider_key and context["providerKey"] != provider_key: - return False - - inference_api = match.get("inferenceApi") - if inference_api and context["inferenceApi"] != inference_api: - return False - - base_url = match.get("baseUrl") - if base_url and context["baseUrl"].rstrip("/") != base_url.rstrip("/"): - return False - - return True - - -def _matching_model_specific_setups(agent: str, context: dict, env: dict) -> list[dict]: - registry_root = _find_registry_root(env) - if registry_root is None: - return [] - - manifests: list[dict] = [] - for manifest_path in sorted(registry_root.glob("**/*.json")): - if manifest_path.name == "schema.json": - continue - with open(manifest_path, "r", encoding="utf-8") as manifest_file: - payload = _validate_manifest_payload(json.load(manifest_file), manifest_path) - if payload["agent"] != agent: - continue - _validate_selected_agent_effects(payload, manifest_path, registry_root) - if _model_setup_matches(payload, context): - manifests.append(payload) - return manifests - - -def _coerce_compat_dict(value: object) -> dict: - if value is None: - return {} - if isinstance(value, dict): - return value - raise ValueError("NEMOCLAW_INFERENCE_COMPAT_B64 must decode to a JSON object or null") - - -def _apply_openclaw_setup_effects( - setup: dict, - inference_compat: dict, - openclaw_plugins: list[dict], - plugin_ids: set[str], - openclaw_tools: dict, -) -> None: - effects = setup["effects"] - for key, value in effects.get("openclawCompat", {}).items(): - if key in inference_compat and inference_compat[key] != value: - raise ValueError( - "model-specific setup " - f"'{setup['id']}' conflicts with inference compat key '{key}'" - ) - inference_compat[key] = value - - for key, value in effects.get("openclawTools", {}).items(): - if key in openclaw_tools and openclaw_tools[key] != value: - raise ValueError( - "model-specific setup " - f"'{setup['id']}' conflicts with OpenClaw tools key '{key}'" - ) - openclaw_tools[key] = value - - for plugin in effects.get("openclawPlugins", []): - plugin_id = plugin["id"] - if plugin_id in plugin_ids: - raise ValueError( - "model-specific setup " - f"'{setup['id']}' declares duplicate OpenClaw plugin '{plugin_id}'" - ) - plugin_ids.add(plugin_id) - openclaw_plugins.append(plugin) - - -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}" - emit_openclaw_managed_proxy = _truthy_env_default( - env, - "NEMOCLAW_OPENCLAW_MANAGED_PROXY", - True, - ) - model = env["NEMOCLAW_MODEL"] - raw_chat_ui_url = env.get("CHAT_UI_URL") or "" - chat_ui_url = raw_chat_ui_url or f"http://127.0.0.1:{DEFAULT_DASHBOARD_PORT}" - gateway_port = _resolve_gateway_port(env, chat_ui_url) - if (env.get("NEMOCLAW_DASHBOARD_PORT") or "").strip() and ( - not raw_chat_ui_url - or raw_chat_ui_url == f"http://127.0.0.1:{DEFAULT_DASHBOARD_PORT}" - ): - chat_ui_url = f"http://127.0.0.1:{gateway_port}" - 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 = _coerce_positive_int(env, "NEMOCLAW_CONTEXT_WINDOW", 131072) - max_tokens = _coerce_positive_int(env, "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) - - # NemoClaw#2880: expose OpenClaw's agents.defaults.heartbeat.every so users - # can disable the periodic heartbeat (e.g. "0m") without editing - # openclaw.json by hand. Accept a Go-style duration string (digits + a - # required s/m/h suffix — OpenClaw docs always show the suffixed form). - # Empty/unset preserves the OpenClaw default. - _raw_heartbeat = (env.get("NEMOCLAW_AGENT_HEARTBEAT_EVERY") or "").strip() - if _raw_heartbeat and not re.match(r"^\d+(s|m|h)$", _raw_heartbeat): - print( - f'[SECURITY] NEMOCLAW_AGENT_HEARTBEAT_EVERY must match ^\\d+(s|m|h)$, ' - f'got "{_raw_heartbeat}" — skipping override, preserving OpenClaw default', - file=sys.stderr, - ) - _raw_heartbeat = "" - agent_heartbeat = _raw_heartbeat - - model_specific_setups = _matching_model_specific_setups( - "openclaw", - { - "model": model, - "providerKey": provider_key, - "baseUrl": inference_base_url, - "inferenceApi": inference_api, - }, - env, - ) - - inference_compat = _coerce_compat_dict( - json.loads( - base64.b64decode(env["NEMOCLAW_INFERENCE_COMPAT_B64"]).decode("utf-8") - ) - ) - openclaw_plugins: list[dict] = [] - openclaw_plugin_ids: set[str] = set() - openclaw_tool_overrides: dict = {} - for setup in model_specific_setups: - _apply_openclaw_setup_effects( - setup, - inference_compat, - openclaw_plugins, - openclaw_plugin_ids, - openclaw_tool_overrides, - ) - openclaw_tools = {"toolSearch": True, **openclaw_tool_overrides} - - # Ollama's OpenAI-compatible /v1/chat/completions stream omits the - # `usage` chunk by default; OpenAI clients have to send - # `stream_options.include_usage: true` to receive it. OpenClaw gates - # that request flag on `model.compat.supportsUsageInStreaming` - # (src/agents/openai-transport-stream.ts) and its Ollama extension - # only opts in when its own detector recognises the endpoint as - # Ollama. NemoClaw routes ollama-local traffic via the standardised - # `https://inference.local/v1` URL through the OpenShell gateway, so - # the upstream detector misses it and the TUI token counter stays - # `?` indefinitely (#2747). Set the flag here so the request is sent - # with `stream_options.include_usage: true` regardless of how - # OpenClaw resolves the provider id. Mirrors the LM Studio extension - # workaround (`withLmstudioUsageCompat` in - # extensions/lmstudio/src/stream.ts). Keep the set of provider keys - # in sync with `_bundled_provider_plugins["ollama"]` below. - if provider_key in {"ollama", "ollama-local"}: - inference_compat.setdefault("supportsUsageInStreaming", True) - - 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") - ) - _telegram_config = json.loads( - base64.b64decode( - env.get("NEMOCLAW_TELEGRAM_CONFIG_B64", "e30=") or "e30=" - ).decode("utf-8") - ) - _slack_config = json.loads( - base64.b64decode( - env.get("NEMOCLAW_SLACK_CONFIG_B64", "e30=") or "e30=" - ).decode("utf-8") - ) - _raw_slack_channels = ( - _slack_config.get("allowedChannels") - if isinstance(_slack_config, dict) - else [] - ) - _slack_allowed_channels = ( - list( - dict.fromkeys( - str(channel).replace("\r", "").replace("\n", "").strip() - for channel in _raw_slack_channels - if str(channel).replace("\r", "").replace("\n", "").strip() - ) - ) - if isinstance(_raw_slack_channels, list) - else [] - ) - # NEMOCLAW_WECHAT_CONFIG_B64 is intentionally not decoded here. The - # WeChat plugin's per-account state (accountId/baseUrl/userId) is read by - # seed-wechat-accounts.py, which runs after the base image has installed - # the WeChat plugin and registered its metadata/channel id. - # Decoding it here too would create a misleading second consumer that - # nothing acts on. - - _token_keys = { - "discord": "token", - "telegram": "botToken", - "slack": "botToken", - } - _env_keys = { - "discord": "DISCORD_BOT_TOKEN", - "telegram": "TELEGRAM_BOT_TOKEN", - "slack": "SLACK_BOT_TOKEN", - } - - # Slack's Bolt SDK validates token shape at App construction (^xoxb-…$ / - # ^xapp-…$) before any HTTP call leaves the process, so the canonical - # openshell:resolve:env:VAR placeholder is rejected synchronously. Emit a - # Bolt-regex-compatible placeholder instead; OpenShell resolves the - # provider-shaped alias directly at the egress boundary. - def _placeholder(channel: str, env_key: str) -> str: - if channel == "slack" and env_key == "SLACK_BOT_TOKEN": - return f"xoxb-OPENSHELL-RESOLVE-ENV-{env_key}" - if channel == "slack" and env_key == "SLACK_APP_TOKEN": - return f"xapp-OPENSHELL-RESOLVE-ENV-{env_key}" - return f"openshell:resolve:env:{env_key}" - - _ch_cfg = {} - for ch in msg_channels: - if ch == "whatsapp": - _ch_cfg[ch] = { - "accounts": { - "default": { - "enabled": True, - "healthMonitor": {"enabled": False}, - } - } - } - continue - if ch not in _token_keys: - continue - account = { - _token_keys[ch]: _placeholder(ch, _env_keys[ch]), - "enabled": True, - "healthMonitor": {"enabled": False}, - } - if ch == "slack": - account["appToken"] = _placeholder(ch, "SLACK_APP_TOKEN") - if ch == "telegram": - 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] - if ch == "slack": - account["groupPolicy"] = "allowlist" - account["channels"] = { - "*": { - "enabled": True, - "requireMention": True, - "users": _allowed_ids[ch], - } - } - if ch == "slack" and _slack_allowed_channels: - account["groupPolicy"] = "allowlist" - slack_channel_config = { - "enabled": True, - "requireMention": True, - } - if ch in _allowed_ids and _allowed_ids[ch]: - slack_channel_config["users"] = _allowed_ids[ch] - account["channels"] = { - channel_id: dict(slack_channel_config) - for channel_id in _slack_allowed_channels - } - _ch_cfg[ch] = {"accounts": {"default": account}} - - # WeChat (openclaw-weixin) is NOT added to channels.* here in build - # contexts where the plugin has not been installed yet — writing it upfront - # makes `openclaw plugins install` fail with "unknown channel id: - # openclaw-weixin" because the plugin registry hasn't seen the channel yet - # (chicken-and-egg). When the base image has already installed the plugin, - # scripts/seed-wechat-accounts.py adds: - # channels.openclaw-weixin.channelConfigUpdatedAt = - # channels.openclaw-weixin.accounts..enabled = true - # The upstream plugin's auth/accounts.ts reads that block at boot to - # decide which accounts to start; without enabled=true the bridge no-ops. - # - # Per-account secrets (token, baseUrl, userId) still live in the plugin's - # own state dir at /openclaw-weixin/accounts/.json - # (also seeded by seed-wechat-accounts.py). DM allowlist uses the - # framework allowFrom file at credentials/openclaw-weixin-{accountId}- - # allowFrom.json — not the openclaw.json accounts..allowFrom mechanism - # that telegram/discord/slack use. - if "discord" in _ch_cfg and _discord_guilds: - _ch_cfg["discord"].update( - {"groupPolicy": "allowlist", "guilds": _discord_guilds} - ) - - if "telegram" in _ch_cfg and _telegram_config.get("requireMention"): - _ch_cfg["telegram"]["groups"] = {"*": {"requireMention": True}} - - # Normalize schemeless URLs before parsing — urlparse("remote-host:18789") - # misclassifies hostname as scheme. Mirrors ensureScheme() in dashboard-contract.ts. - _normalized_url = _normalize_url_for_parse(chat_ui_url) - - parsed = urlparse(_normalized_url) - loopback_origin = f"http://127.0.0.1:{gateway_port}" - chat_origin = ( - f"{parsed.scheme}://{parsed.netloc}" - if parsed.scheme and parsed.netloc - else loopback_origin - ) - # When onboard injects an internal port (e.g. :18789) into a URL that the - # user provided without an explicit port, the browser origin from a reverse - # proxy (Brev Cloudflare Tunnel, nginx, Caddy, etc.) will not carry that - # port. Include the portless origin so both direct and proxied access work. - # Skip for loopback — no reverse proxy in front of localhost. - try: - _has_explicit_port = parsed.port is not None - except ValueError: - _has_explicit_port = False - if parsed.scheme and parsed.hostname and _has_explicit_port and not is_loopback(parsed.hostname): - host_part = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname - portless_origin = f"{parsed.scheme}://{host_part}" - else: - portless_origin = None - origins = list(dict.fromkeys(filter(None, [loopback_origin, chat_origin, portless_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, - } - ], - } - } - - # OpenClaw stages runtime dependencies for every bundled enabledByDefault - # provider plugin. NemoClaw bakes one model provider into openclaw.json, so - # keeping unused default providers enabled bloats image builds and, once the - # gateway has write access to plugin-runtime-deps, can stall first startup. - plugin_entries = { - "acpx": {"enabled": False}, - "bonjour": {"enabled": False}, - "qqbot": {"enabled": False}, - # The @tencent-weixin/openclaw-weixin plugin is pre-installed in the - # base image (Dockerfile.base) so onboarding does not depend on the - # public npm registry for it. Enable the entry unconditionally — the - # bridge no-ops at startup unless seed-wechat-accounts.py has also - # registered an accountId under channels.openclaw-weixin.accounts. - "openclaw-weixin": {"enabled": True}, - } - _bundled_provider_plugins = { - "amazon-bedrock": {"amazon-bedrock", "bedrock"}, - "amazon-bedrock-mantle": {"amazon-bedrock-mantle"}, - "anthropic": {"anthropic"}, - "anthropic-vertex": {"anthropic-vertex"}, - "fireworks": {"fireworks"}, - "google": {"google", "google-gemini-cli"}, - "kimi": {"kimi"}, - "lmstudio": {"lmstudio"}, - "ollama": {"ollama", "ollama-local"}, - "openai": {"openai"}, - "xai": {"xai"}, - } - for _plugin_id, _provider_keys in _bundled_provider_plugins.items(): - if provider_key not in _provider_keys: - plugin_entries[_plugin_id] = {"enabled": False} - if "discord" in _ch_cfg: - plugin_entries["discord"] = {"enabled": True} - plugins = {"entries": plugin_entries} - plugin_load_paths: list[str] = [] - for plugin in openclaw_plugins: - plugin_entries[plugin["id"]] = {"enabled": True} - if plugin["loadPath"] not in plugin_load_paths: - plugin_load_paths.append(plugin["loadPath"]) - if plugin_load_paths: - plugins["load"] = {"paths": plugin_load_paths} - - config = { - "agents": { - "defaults": { - "model": {"primary": primary_model_ref}, - "timeoutSeconds": agent_timeout, - **( - {"heartbeat": {"every": agent_heartbeat}} - if agent_heartbeat - else {} - ), - # NemoClaw sandboxes are provisioned non-interactively and the - # E2E CLI contract expects the first agent turn to answer the - # caller's prompt. OpenClaw 2026.4.24+ seeds BOOTSTRAP.md by - # default, which redirects a fresh workspace into an identity - # setup conversation before normal replies. - "skipBootstrap": True, - # Keep first-turn smoke checks on the lowest-latency path. - # OpenClaw can infer thinking defaults from the model catalog; - # NemoClaw's sandbox contract is a direct CLI answer, not an - # interactive reasoning session. - "thinkingDefault": "off", - } - }, - "models": {"mode": "merge", "providers": providers}, - "channels": {"defaults": {}, **_ch_cfg}, - "tools": openclaw_tools, - "update": {"checkOnStart": False}, - # Disable bundled plugins/channels that hit the L7 proxy at startup - # and either crash or hang the gateway: - # - # bonjour — uses @homebridge/ciao for mDNS announcement; sandbox - # netns has no multicast, ciao either fails sync via - # uv_interface_addresses or async via "CIAO PROBING CANCELLED". - # Introduced in OpenClaw 2026.4.15. See NemoClaw#2484. - # - # qqbot — has stageRuntimeDependencies=true, so its npm deps - # (@tencent-connect/qqbot-connector et al.) install on first - # load. The sandbox L7 proxy denies the registry URL, the - # install retries for ~6 minutes, and while it's stuck the - # gateway can't service openclaw-agent requests — that's the - # TC-SBX-02 hang observed in 2026.4.24. - # - # acpx is disabled by default because its runtime dependency staging - # also reaches npm during gateway startup. NemoClaw's primary CLI path - # invokes openclaw-agent directly, not ACPx. - # - # Provider plugins with staged runtime dependencies are disabled above - # unless they match NEMOCLAW_PROVIDER_KEY. That keeps the baked image - # limited to the provider selected during onboard. - "plugins": plugins, - "gateway": { - "mode": "local", - "port": gateway_port, - "controlUi": { - "allowInsecureAuth": allow_insecure, - "dangerouslyDisableDeviceAuth": disable_device_auth, - "allowedOrigins": origins, - }, - "trustedProxies": ["127.0.0.1", "::1"], - "auth": {"token": ""}, - }, - } - - if emit_openclaw_managed_proxy: - config["proxy"] = { - "enabled": True, - "proxyUrl": proxy_url, - "loopbackMode": "proxy", - } - - # Keep keyless web_fetch available by default, but force it through the - # trusted env proxy. OpenShell's L7 policy remains the egress authority: - # without an approved host:port, the proxy denies the request. Remove this - # default only if OpenClaw gains a first-class least-privilege web_fetch - # policy that can preserve host-gateway fetch without bypassing OpenShell. - tools_web = config.setdefault("tools", {}).setdefault("web", {}) - tools_web["fetch"] = {"enabled": True, "useTrustedEnvProxy": True} - - if env.get("NEMOCLAW_WEB_SEARCH_ENABLED", "") == "1": - tools_web["search"] = { - "enabled": True, - "provider": "brave", - "apiKey": "openshell:resolve:env:BRAVE_API_KEY", - } - - return config - - -def _preserve_existing_plugin_installs(config: dict, path: str) -> None: - try: - with open(path) as f: - existing = json.load(f) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return - - if not isinstance(existing, dict): - return - existing_plugins = existing.get("plugins") - if not isinstance(existing_plugins, dict): - return - existing_installs = existing_plugins.get("installs") - if not isinstance(existing_installs, dict) or not existing_installs: - return - - plugins = config.setdefault("plugins", {}) - current_installs = plugins.get("installs") - if not isinstance(current_installs, dict): - current_installs = {} - plugins["installs"] = {**existing_installs, **current_installs} - - -def _has_plugin_install(config: dict, plugin_id: str) -> bool: - plugins = config.get("plugins") - if not isinstance(plugins, dict): - return False - installs = plugins.get("installs") - return isinstance(installs, dict) and plugin_id in installs - - -def _has_installed_wechat_plugin_metadata() -> bool: - package_dir = ( - Path(os.path.expanduser("~/.openclaw")) - / "npm" - / "node_modules" - / "@tencent-weixin" - / "openclaw-weixin" - ) - for filename in ("openclaw.plugin.json", "package.json"): - path = package_dir / filename - try: - metadata = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if isinstance(metadata, dict) and ( - metadata.get("id") == "openclaw-weixin" - or metadata.get("name") == "@tencent-weixin/openclaw-weixin" - or "openclaw-weixin" in str(path).lower() - ): - return True - - extensions_dir = Path(os.path.expanduser("~/.openclaw/extensions")) - if not extensions_dir.exists(): - return False - - for root, dirs, files in os.walk(extensions_dir): - dirs[:] = [ - item - for item in dirs - if item not in {"node_modules", "plugin-runtime-deps", ".git"} - ] - root_path = Path(root) - for filename in files: - if filename not in {"openclaw.plugin.json", "package.json"}: - continue - path = root_path / filename - try: - metadata = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if not isinstance(metadata, dict): - continue - if ( - metadata.get("id") == "openclaw-weixin" - or metadata.get("name") == "@tencent-weixin/openclaw-weixin" - or "openclaw-weixin" in str(path).lower() - ): - return True - return False - - -def _has_preinstalled_wechat_plugin_signal() -> bool: - return os.environ.get("NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED", "").strip().lower() in { - "1", - "true", - "yes", - "on", - } - - -def _seed_wechat_accounts_if_available(config: dict) -> None: - if ( - not _has_plugin_install(config, "openclaw-weixin") - and not _has_installed_wechat_plugin_metadata() - and not _has_preinstalled_wechat_plugin_signal() - ): - return - - seed_script = Path(__file__).resolve().with_name("seed-wechat-accounts.py") - namespace = runpy.run_path(str(seed_script)) - main = namespace.get("main") - if not callable(main): - raise RuntimeError(f"{seed_script} does not expose main()") - exit_code = main() - if exit_code not in (None, 0): - raise SystemExit(exit_code) - - -def main() -> None: - """Generate openclaw.json from environment variables.""" - config = build_config() - path = os.path.expanduser("~/.openclaw/openclaw.json") - _preserve_existing_plugin_installs(config, path) - 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) - _seed_wechat_accounts_if_available(config) - - -if __name__ == "__main__": - main() diff --git a/scripts/generate-openclaw-config.ts b/scripts/generate-openclaw-config.ts new file mode 100755 index 0000000000..cdb7cb25ba --- /dev/null +++ b/scripts/generate-openclaw-config.ts @@ -0,0 +1,976 @@ +#!/usr/bin/env -S node --experimental-strip-types +// 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 after ARG->ENV promotion. Reads all +// configuration from process.env, never from Dockerfile source interpolation. +// +// Main inputs: +// CHAT_UI_URL, NEMOCLAW_DASHBOARD_PORT, NEMOCLAW_MODEL, +// NEMOCLAW_PROVIDER_KEY, NEMOCLAW_PRIMARY_MODEL_REF, +// NEMOCLAW_INFERENCE_BASE_URL, NEMOCLAW_INFERENCE_API, +// NEMOCLAW_INFERENCE_INPUTS, NEMOCLAW_CONTEXT_WINDOW, +// NEMOCLAW_MAX_TOKENS, NEMOCLAW_REASONING, +// NEMOCLAW_AGENT_TIMEOUT, NEMOCLAW_AGENT_HEARTBEAT_EVERY, +// NEMOCLAW_INFERENCE_COMPAT_B64, NEMOCLAW_MESSAGING_CHANNELS_B64, +// NEMOCLAW_MESSAGING_ALLOWED_IDS_B64, NEMOCLAW_DISCORD_GUILDS_B64, +// NEMOCLAW_TELEGRAM_CONFIG_B64, NEMOCLAW_WECHAT_CONFIG_B64, +// NEMOCLAW_SLACK_CONFIG_B64, NEMOCLAW_DISABLE_DEVICE_AUTH, +// NEMOCLAW_PROXY_HOST, NEMOCLAW_PROXY_PORT, +// NEMOCLAW_OPENCLAW_MANAGED_PROXY, NEMOCLAW_WEB_SEARCH_ENABLED. + +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +type Env = Record; +type JsonObject = Record; + +const KNOWN_MODEL_SETUP_AGENTS = new Set(["openclaw", "hermes"]); +const MODEL_SETUP_EFFECT_KEYS: Record> = { + openclaw: new Set(["openclawCompat", "openclawPlugins", "openclawTools"]), + hermes: new Set(["hermesCompat"]), +}; +const DEFAULT_DASHBOARD_PORT = 18789; +const MIN_DASHBOARD_PORT = 1024; +const MAX_DASHBOARD_PORT = 65535; +const FALSE_VALUES = new Set(["0", "false", "no", "off"]); +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const SCRIPT_DIR = dirname(SCRIPT_PATH); + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function unique(values: Iterable): T[] { + return [...new Set(values)]; +} + +function expandUser(pathValue: string): string { + if (pathValue === "~") { + return process.env.HOME || pathValue; + } + if (pathValue.startsWith(`~${sep}`) || pathValue.startsWith("~/")) { + return join(process.env.HOME || "~", pathValue.slice(2)); + } + return pathValue; +} + +function coercePositiveInt(env: Env, name: string, defaultValue: number): number { + const raw = env[name] || String(defaultValue); + let value = 0; + if (/^\d+$/.test(raw) && raw.length < 1000) { + const parsed = Number(raw); + if (Number.isSafeInteger(parsed)) { + value = parsed; + } + } + if (value > 0) { + return value; + } + console.error( + `[SECURITY] ${name} must be a positive integer, got "${raw}" ` + + `-- skipping override, falling back to default (${defaultValue})`, + ); + return defaultValue; +} + +function isLoopback(hostname: string): boolean { + const normalized = (hostname || "").trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, ""); + if (normalized === "localhost" || normalized === "::1") { + return true; + } + return /^127(?:\.\d{1,3}){3}$/.test(normalized); +} + +function normalizeUrlForParse(rawUrl: string): string { + if (rawUrl && !/^[a-z][a-z0-9+.-]*:\/\//i.test(rawUrl)) { + return `http://${rawUrl}`; + } + return rawUrl; +} + +function truthyEnvDefault(env: Env, name: string, defaultValue: boolean): boolean { + const raw = env[name]; + if (raw === undefined || raw.trim() === "") { + return defaultValue; + } + return !FALSE_VALUES.has(raw.trim().toLowerCase()); +} + +function validateDashboardPort(raw: string, envName: string): number { + const stripped = raw.trim(); + if (!/^\d+$/.test(stripped)) { + throw new Error(`${envName} must be an integer between 1024 and 65535`); + } + const value = Number(stripped); + if (value < MIN_DASHBOARD_PORT || value > MAX_DASHBOARD_PORT) { + throw new Error(`${envName} must be an integer between 1024 and 65535`); + } + return value; +} + +type ParsedUrl = { + scheme: string; + netloc: string; + hostname: string; + port: number | null; +}; + +function parseUrl(rawUrl: string): ParsedUrl { + const match = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)(?:[/?#].*)?$/i.exec(rawUrl); + if (!match) { + return { scheme: "", netloc: "", hostname: "", port: null }; + } + + const scheme = match[1]; + const netloc = match[2]; + let hostname = netloc; + let portText: string | null = null; + + if (netloc.startsWith("[")) { + const end = netloc.indexOf("]"); + if (end >= 0) { + hostname = netloc.slice(1, end); + const rest = netloc.slice(end + 1); + if (rest.startsWith(":")) { + portText = rest.slice(1); + } + } + } else { + const colon = netloc.lastIndexOf(":"); + if (colon >= 0 && netloc.indexOf(":") === colon) { + hostname = netloc.slice(0, colon); + portText = netloc.slice(colon + 1); + } + } + + let port: number | null = null; + if (portText !== null && /^\d+$/.test(portText)) { + const value = Number(portText); + if (value >= 0 && value <= 65535) { + port = value; + } + } + + return { scheme, netloc, hostname: hostname.toLowerCase(), port }; +} + +function chatUiUrlPort(chatUiUrl: string): number | null { + const parsed = parseUrl(normalizeUrlForParse(chatUiUrl)); + if (parsed.port === null) { + return null; + } + if (parsed.port < MIN_DASHBOARD_PORT || parsed.port > MAX_DASHBOARD_PORT) { + return null; + } + return parsed.port; +} + +function resolveGatewayPort(env: Env, chatUiUrl: string): number { + const rawDashboardPort = env.NEMOCLAW_DASHBOARD_PORT || ""; + if (rawDashboardPort.trim()) { + return validateDashboardPort(rawDashboardPort, "NEMOCLAW_DASHBOARD_PORT"); + } + return chatUiUrlPort(chatUiUrl) || DEFAULT_DASHBOARD_PORT; +} + +function registryRoots(env: Env): string[] { + const roots: string[] = []; + const explicit = env.NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR; + if (explicit) { + roots.push(explicit); + } + roots.push( + "/opt/nemoclaw-blueprint/model-specific-setup", + "/sandbox/.nemoclaw/blueprints/0.1.0/model-specific-setup", + join(dirname(SCRIPT_DIR), "nemoclaw-blueprint", "model-specific-setup"), + join(process.cwd(), "nemoclaw-blueprint", "model-specific-setup"), + ); + return unique(roots); +} + +function isDirectory(pathValue: string): boolean { + try { + return statSync(pathValue).isDirectory(); + } catch { + return false; + } +} + +function findRegistryRoot(env: Env): string | null { + const explicit = env.NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR; + if (explicit) { + if (!isDirectory(explicit)) { + throw new Error( + "NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR must point to an existing directory: " + explicit, + ); + } + return explicit; + } + + for (const root of registryRoots(env)) { + if (isDirectory(root)) { + return root; + } + } + return null; +} + +function validateManifestPayload(payload: unknown, manifestPath: string): JsonObject { + if (!isObject(payload)) { + throw new Error(`${manifestPath}: manifest must be a JSON object`); + } + + const setupId = payload.id; + if (typeof setupId !== "string" || !setupId.trim()) { + throw new Error(`${manifestPath}: field 'id' must be a non-empty string`); + } + + const agent = payload.agent; + if (typeof agent !== "string" || !agent.trim()) { + throw new Error(`${manifestPath}: field 'agent' is required`); + } + if (!KNOWN_MODEL_SETUP_AGENTS.has(agent)) { + throw new Error(`${manifestPath}: unknown agent '${agent}'`); + } + + const description = payload.description; + if (typeof description !== "string" || !description.trim()) { + throw new Error(`${manifestPath}: field 'description' must be a non-empty string`); + } + + const match = payload.match; + if (!isObject(match)) { + throw new Error(`${manifestPath}: field 'match' must be an object`); + } + if (Object.keys(match).length === 0) { + throw new Error(`${manifestPath}: field 'match' must be a non-empty object`); + } + const allowedMatchKeys = new Set(["modelIds", "providerKey", "inferenceApi", "baseUrl"]); + const unknownMatchKeys = Object.keys(match) + .filter((key) => !allowedMatchKeys.has(key)) + .sort(); + if (unknownMatchKeys.length > 0) { + throw new Error(`${manifestPath}: unknown match keys: ${unknownMatchKeys.join(", ")}`); + } + + const modelIds = match.modelIds; + if ( + modelIds !== undefined && + (!Array.isArray(modelIds) || + modelIds.length === 0 || + !modelIds.every((modelId) => typeof modelId === "string" && modelId.trim())) + ) { + throw new Error(`${manifestPath}: match.modelIds must be a non-empty string array`); + } + for (const key of ["providerKey", "inferenceApi", "baseUrl"]) { + const value = match[key]; + if (value !== undefined && (typeof value !== "string" || !value.trim())) { + throw new Error(`${manifestPath}: match.${key} must be a non-empty string`); + } + } + + const effects = payload.effects; + if (!isObject(effects) || Object.keys(effects).length === 0) { + throw new Error(`${manifestPath}: field 'effects' must be a non-empty object`); + } + + return payload; +} + +function validateSelectedAgentEffects( + payload: JsonObject, + manifestPath: string, + registryRoot: string, +): void { + const agent = payload.agent; + const effects = payload.effects; + const allowedEffectKeys = MODEL_SETUP_EFFECT_KEYS[agent]; + const unknownEffectKeys = Object.keys(effects) + .filter((key) => !allowedEffectKeys.has(key)) + .sort(); + if (unknownEffectKeys.length > 0) { + throw new Error( + `${manifestPath}: unknown effects for agent '${agent}': ${unknownEffectKeys.join(", ")}`, + ); + } + + if (agent === "openclaw") { + const compat = effects.openclawCompat; + if (compat !== undefined && !isObject(compat)) { + throw new Error(`${manifestPath}: effects.openclawCompat must be an object`); + } + + const tools = effects.openclawTools; + if (tools !== undefined) { + if (!isObject(tools)) { + throw new Error(`${manifestPath}: effects.openclawTools must be an object`); + } + const unknownToolKeys = Object.keys(tools) + .filter((key) => key !== "toolSearch") + .sort(); + if (unknownToolKeys.length > 0) { + throw new Error( + `${manifestPath}: unknown effects.openclawTools keys: ${unknownToolKeys.join(", ")}`, + ); + } + if ("toolSearch" in tools && typeof tools.toolSearch !== "boolean") { + throw new Error(`${manifestPath}: effects.openclawTools.toolSearch must be a boolean`); + } + } + + const plugins = effects.openclawPlugins || []; + if (!Array.isArray(plugins)) { + throw new Error(`${manifestPath}: effects.openclawPlugins must be an array`); + } + plugins.forEach((plugin, index) => { + if (!isObject(plugin)) { + throw new Error(`${manifestPath}: effects.openclawPlugins[${index}] must be an object`); + } + for (const key of ["id", "path", "loadPath"]) { + const value = plugin[key]; + if (typeof value !== "string" || !value.trim()) { + throw new Error( + `${manifestPath}: effects.openclawPlugins[${index}].${key} ` + + "must be a non-empty string", + ); + } + } + const sourcePath = plugin.path as string; + const sourceParts = sourcePath.split(/[\\/]+/); + if (isAbsolute(sourcePath) || sourceParts.includes("..")) { + throw new Error( + `${manifestPath}: effects.openclawPlugins[${index}].path ` + + "must be relative to nemoclaw-blueprint", + ); + } + if (!existsSync(join(dirname(registryRoot), sourcePath))) { + throw new Error( + `${manifestPath}: effects.openclawPlugins[${index}].path does not exist: ` + sourcePath, + ); + } + const strippedPath = sourcePath.replace(/^\/+/, "").replace(/\/+$/, ""); + const expectedLoadPath = `/usr/local/share/nemoclaw/${strippedPath}`; + if ((plugin.loadPath as string).replace(/\/+$/, "") !== expectedLoadPath) { + throw new Error( + `${manifestPath}: effects.openclawPlugins[${index}].loadPath ` + + `must be '${expectedLoadPath}'`, + ); + } + }); + } + + if (agent === "hermes") { + const compat = effects.hermesCompat; + if (compat !== undefined && !isObject(compat)) { + throw new Error(`${manifestPath}: effects.hermesCompat must be an object`); + } + } +} + +function modelSetupMatches(payload: JsonObject, context: JsonObject): boolean { + const match = payload.match; + const modelIds = match.modelIds; + if ( + Array.isArray(modelIds) && + modelIds.length > 0 && + !new Set(modelIds.map((modelId) => String(modelId).trim().toLowerCase())).has( + String(context.model).trim().toLowerCase(), + ) + ) { + return false; + } + + const providerKey = match.providerKey; + if (providerKey && context.providerKey !== providerKey) { + return false; + } + + const inferenceApi = match.inferenceApi; + if (inferenceApi && context.inferenceApi !== inferenceApi) { + return false; + } + + const baseUrl = match.baseUrl; + if ( + baseUrl && + String(context.baseUrl).replace(/\/+$/, "") !== String(baseUrl).replace(/\/+$/, "") + ) { + return false; + } + + return true; +} + +function listJsonFiles(root: string): string[] { + const files: string[] = []; + function visit(dir: string): void { + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const pathValue = join(dir, entry.name); + if (entry.isDirectory()) { + visit(pathValue); + } else if (entry.isFile() && entry.name.endsWith(".json")) { + files.push(pathValue); + } + } + } + visit(root); + return files.sort(); +} + +function matchingModelSpecificSetups(agent: string, context: JsonObject, env: Env): JsonObject[] { + const registryRoot = findRegistryRoot(env); + if (registryRoot === null) { + return []; + } + + const manifests: JsonObject[] = []; + for (const manifestPath of listJsonFiles(registryRoot)) { + if (manifestPath.split(sep).at(-1) === "schema.json") { + continue; + } + const payload = validateManifestPayload( + JSON.parse(readFileSync(manifestPath, "utf-8")), + manifestPath, + ); + if (payload.agent !== agent) { + continue; + } + validateSelectedAgentEffects(payload, manifestPath, registryRoot); + if (modelSetupMatches(payload, context)) { + manifests.push(payload); + } + } + return manifests; +} + +function coerceCompatDict(value: unknown): JsonObject { + if (value === null || value === undefined) { + return {}; + } + if (isObject(value)) { + return value; + } + throw new Error("NEMOCLAW_INFERENCE_COMPAT_B64 must decode to a JSON object or null"); +} + +function applyOpenClawSetupEffects( + setup: JsonObject, + inferenceCompat: JsonObject, + openclawPlugins: JsonObject[], + pluginIds: Set, + openclawTools: JsonObject, +): void { + const effects = setup.effects; + for (const [key, value] of Object.entries(effects.openclawCompat || {})) { + if (key in inferenceCompat && inferenceCompat[key] !== value) { + throw new Error( + `model-specific setup '${setup.id}' conflicts with inference compat key '${key}'`, + ); + } + inferenceCompat[key] = value; + } + + for (const [key, value] of Object.entries(effects.openclawTools || {})) { + if (key in openclawTools && openclawTools[key] !== value) { + throw new Error( + `model-specific setup '${setup.id}' conflicts with OpenClaw tools key '${key}'`, + ); + } + openclawTools[key] = value; + } + + for (const plugin of effects.openclawPlugins || []) { + const pluginId = plugin.id; + if (pluginIds.has(pluginId)) { + throw new Error( + `model-specific setup '${setup.id}' declares duplicate OpenClaw plugin '${pluginId}'`, + ); + } + pluginIds.add(pluginId); + openclawPlugins.push(plugin); + } +} + +function decodeJsonEnv(env: Env, name: string, defaultValue: string): any { + const raw = env[name] || defaultValue; + return JSON.parse(Buffer.from(raw, "base64").toString("utf-8")); +} + +function buildConfig(env: Env = process.env): JsonObject { + const proxyHost = env.NEMOCLAW_PROXY_HOST || "10.200.0.1"; + const proxyPort = env.NEMOCLAW_PROXY_PORT || "3128"; + const proxyUrl = `http://${proxyHost}:${proxyPort}`; + const emitOpenClawManagedProxy = truthyEnvDefault(env, "NEMOCLAW_OPENCLAW_MANAGED_PROXY", true); + const model = env.NEMOCLAW_MODEL as string; + const rawChatUiUrl = env.CHAT_UI_URL || ""; + let chatUiUrl = rawChatUiUrl || `http://127.0.0.1:${DEFAULT_DASHBOARD_PORT}`; + const gatewayPort = resolveGatewayPort(env, chatUiUrl); + if ( + (env.NEMOCLAW_DASHBOARD_PORT || "").trim() && + (!rawChatUiUrl || rawChatUiUrl === `http://127.0.0.1:${DEFAULT_DASHBOARD_PORT}`) + ) { + chatUiUrl = `http://127.0.0.1:${gatewayPort}`; + } + const providerKey = env.NEMOCLAW_PROVIDER_KEY as string; + const primaryModelRef = env.NEMOCLAW_PRIMARY_MODEL_REF as string; + const inferenceBaseUrl = env.NEMOCLAW_INFERENCE_BASE_URL as string; + const inferenceApi = env.NEMOCLAW_INFERENCE_API as string; + const contextWindow = coercePositiveInt(env, "NEMOCLAW_CONTEXT_WINDOW", 131072); + const maxTokens = coercePositiveInt(env, "NEMOCLAW_MAX_TOKENS", 4096); + + const reasoning = (env.NEMOCLAW_REASONING || "false") === "true"; + const inferenceInputs = (env.NEMOCLAW_INFERENCE_INPUTS || "text") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + if (inferenceInputs.length === 0) { + inferenceInputs.push("text"); + } + + const rawAgentTimeout = env.NEMOCLAW_AGENT_TIMEOUT || "600"; + const parsedAgentTimeout = /^\d+$/.test(rawAgentTimeout) ? Number(rawAgentTimeout) : 0; + if (!Number.isSafeInteger(parsedAgentTimeout) || parsedAgentTimeout <= 0) { + throw new Error("NEMOCLAW_AGENT_TIMEOUT must be a positive integer"); + } + const agentTimeout = parsedAgentTimeout; + + let agentHeartbeat = (env.NEMOCLAW_AGENT_HEARTBEAT_EVERY || "").trim(); + if (agentHeartbeat && !/^\d+(s|m|h)$/.test(agentHeartbeat)) { + console.error( + `[SECURITY] NEMOCLAW_AGENT_HEARTBEAT_EVERY must match ^\\d+(s|m|h)$, ` + + `got "${agentHeartbeat}" -- skipping override, preserving OpenClaw default`, + ); + agentHeartbeat = ""; + } + + const modelSpecificSetups = matchingModelSpecificSetups( + "openclaw", + { + model, + providerKey, + baseUrl: inferenceBaseUrl, + inferenceApi, + }, + env, + ); + + const inferenceCompat = coerceCompatDict( + decodeJsonEnv(env, "NEMOCLAW_INFERENCE_COMPAT_B64", "e30="), + ); + const openclawPlugins: JsonObject[] = []; + const openclawPluginIds = new Set(); + const openclawToolOverrides: JsonObject = {}; + for (const setup of modelSpecificSetups) { + applyOpenClawSetupEffects( + setup, + inferenceCompat, + openclawPlugins, + openclawPluginIds, + openclawToolOverrides, + ); + } + const openclawTools: JsonObject = { toolSearch: true, ...openclawToolOverrides }; + + if (providerKey === "ollama" || providerKey === "ollama-local") { + inferenceCompat.supportsUsageInStreaming ??= true; + } + + const msgChannels = decodeJsonEnv(env, "NEMOCLAW_MESSAGING_CHANNELS_B64", "W10="); + const allowedIds = decodeJsonEnv(env, "NEMOCLAW_MESSAGING_ALLOWED_IDS_B64", "e30="); + const discordGuilds = decodeJsonEnv(env, "NEMOCLAW_DISCORD_GUILDS_B64", "e30="); + const telegramConfig = decodeJsonEnv(env, "NEMOCLAW_TELEGRAM_CONFIG_B64", "e30="); + const slackConfig = decodeJsonEnv(env, "NEMOCLAW_SLACK_CONFIG_B64", "e30="); + const rawSlackChannels = isObject(slackConfig) ? slackConfig.allowedChannels : []; + const slackAllowedChannels = Array.isArray(rawSlackChannels) + ? unique( + rawSlackChannels + .map((channel) => String(channel).replaceAll("\r", "").replaceAll("\n", "").trim()) + .filter(Boolean), + ) + : []; + + const tokenKeys: Record = { + discord: "token", + telegram: "botToken", + slack: "botToken", + }; + const envKeys: Record = { + discord: "DISCORD_BOT_TOKEN", + telegram: "TELEGRAM_BOT_TOKEN", + slack: "SLACK_BOT_TOKEN", + }; + + function placeholder(channel: string, envKey: string): string { + if (channel === "slack" && envKey === "SLACK_BOT_TOKEN") { + return `xoxb-OPENSHELL-RESOLVE-ENV-${envKey}`; + } + if (channel === "slack" && envKey === "SLACK_APP_TOKEN") { + return `xapp-OPENSHELL-RESOLVE-ENV-${envKey}`; + } + return `openshell:resolve:env:${envKey}`; + } + + const channelConfig: JsonObject = {}; + for (const channel of Array.isArray(msgChannels) ? msgChannels : []) { + const ch = String(channel); + if (ch === "whatsapp") { + channelConfig[ch] = { + enabled: true, + accounts: { + default: { enabled: true, healthMonitor: { enabled: false } }, + }, + }; + continue; + } + if (!(ch in tokenKeys)) { + continue; + } + const account: JsonObject = { + [tokenKeys[ch]]: placeholder(ch, envKeys[ch]), + enabled: true, + healthMonitor: { enabled: false }, + }; + if (ch === "slack") { + account.appToken = placeholder(ch, "SLACK_APP_TOKEN"); + } + if (ch === "telegram") { + account.proxy = proxyUrl; + account.groupPolicy = "open"; + } + if (isObject(allowedIds) && ch in allowedIds && allowedIds[ch]) { + account.dmPolicy = "allowlist"; + account.allowFrom = allowedIds[ch]; + if (ch === "slack") { + account.groupPolicy = "allowlist"; + account.channels = { + "*": { + enabled: true, + requireMention: true, + users: allowedIds[ch], + }, + }; + } + } + if (ch === "slack" && slackAllowedChannels.length > 0) { + account.groupPolicy = "allowlist"; + const slackChannelConfig: JsonObject = { + enabled: true, + requireMention: true, + }; + if (isObject(allowedIds) && ch in allowedIds && allowedIds[ch]) { + slackChannelConfig.users = allowedIds[ch]; + } + account.channels = Object.fromEntries( + slackAllowedChannels.map((channelId) => [channelId, { ...slackChannelConfig }]), + ); + } + channelConfig[ch] = { enabled: true, accounts: { default: account } }; + } + + if ( + "discord" in channelConfig && + isObject(discordGuilds) && + Object.keys(discordGuilds).length > 0 + ) { + Object.assign(channelConfig.discord, { + groupPolicy: "allowlist", + guilds: discordGuilds, + }); + } + + if ("telegram" in channelConfig && isObject(telegramConfig) && telegramConfig.requireMention) { + channelConfig.telegram.groups = { "*": { requireMention: true } }; + } + + const normalizedUrl = normalizeUrlForParse(chatUiUrl); + const parsed = parseUrl(normalizedUrl); + const loopbackOrigin = `http://127.0.0.1:${gatewayPort}`; + const chatOrigin = + parsed.scheme && parsed.netloc ? `${parsed.scheme}://${parsed.netloc}` : loopbackOrigin; + const portlessOrigin = + parsed.scheme && parsed.hostname && parsed.port !== null && !isLoopback(parsed.hostname) + ? `${parsed.scheme}://${parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname}` + : null; + const origins = unique([loopbackOrigin, chatOrigin, portlessOrigin].filter(Boolean) as string[]); + + const isRemote = !isLoopback(parsed.hostname || ""); + const disableDeviceAuth = env.NEMOCLAW_DISABLE_DEVICE_AUTH === "1" || isRemote; + const allowInsecure = parsed.scheme === "http"; + + const providers = { + [providerKey]: { + baseUrl: inferenceBaseUrl, + apiKey: "unused", + api: inferenceApi, + models: [ + { + ...(Object.keys(inferenceCompat).length > 0 ? { compat: inferenceCompat } : {}), + id: model, + name: primaryModelRef, + reasoning, + input: inferenceInputs, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow, + maxTokens, + }, + ], + }, + }; + + const pluginEntries: JsonObject = { + acpx: { enabled: false }, + bonjour: { enabled: false }, + qqbot: { enabled: false }, + "openclaw-weixin": { enabled: true }, + }; + for (const ch of ["discord", "slack", "telegram", "whatsapp"]) { + if (ch in channelConfig) { + pluginEntries[ch] = { enabled: true }; + } + } + const bundledProviderPlugins: Record> = { + "amazon-bedrock": new Set(["amazon-bedrock", "bedrock"]), + "amazon-bedrock-mantle": new Set(["amazon-bedrock-mantle"]), + anthropic: new Set(["anthropic"]), + "anthropic-vertex": new Set(["anthropic-vertex"]), + fireworks: new Set(["fireworks"]), + google: new Set(["google", "google-gemini-cli"]), + kimi: new Set(["kimi"]), + lmstudio: new Set(["lmstudio"]), + ollama: new Set(["ollama", "ollama-local"]), + openai: new Set(["openai"]), + xai: new Set(["xai"]), + }; + for (const [pluginId, providerKeys] of Object.entries(bundledProviderPlugins)) { + if (!providerKeys.has(providerKey)) { + pluginEntries[pluginId] = { enabled: false }; + } + } + + const plugins: JsonObject = { entries: pluginEntries }; + const pluginLoadPaths: string[] = []; + for (const plugin of openclawPlugins) { + pluginEntries[plugin.id] = { enabled: true }; + if (!pluginLoadPaths.includes(plugin.loadPath)) { + pluginLoadPaths.push(plugin.loadPath); + } + } + if (pluginLoadPaths.length > 0) { + plugins.load = { paths: pluginLoadPaths }; + } + + const agentDefaults: JsonObject = { + model: { primary: primaryModelRef }, + timeoutSeconds: agentTimeout, + ...(agentHeartbeat ? { heartbeat: { every: agentHeartbeat } } : {}), + skipBootstrap: true, + thinkingDefault: "off", + }; + + const config: JsonObject = { + agents: { defaults: agentDefaults }, + models: { mode: "merge", providers }, + channels: { defaults: {}, ...channelConfig }, + tools: openclawTools, + update: { checkOnStart: false }, + plugins, + gateway: { + mode: "local", + port: gatewayPort, + controlUi: { + allowInsecureAuth: allowInsecure, + dangerouslyDisableDeviceAuth: disableDeviceAuth, + allowedOrigins: origins, + }, + trustedProxies: ["127.0.0.1", "::1"], + auth: { token: "" }, + }, + }; + + if (emitOpenClawManagedProxy) { + config.proxy = { + enabled: true, + proxyUrl, + loopbackMode: "proxy", + }; + } + + const tools = config.tools; + tools.web ??= {}; + tools.web.fetch = { enabled: true, useTrustedEnvProxy: true }; + + if (env.NEMOCLAW_WEB_SEARCH_ENABLED === "1") { + tools.web.search = { + enabled: true, + provider: "brave", + apiKey: "openshell:resolve:env:BRAVE_API_KEY", + }; + } + + return config; +} + +function preserveExistingPluginInstalls(config: JsonObject, configPath: string): void { + let existing: unknown; + try { + existing = JSON.parse(readFileSync(configPath, "utf-8")); + } catch { + return; + } + if (!isObject(existing)) { + return; + } + const existingPlugins = existing.plugins; + if (!isObject(existingPlugins)) { + return; + } + const existingInstalls = existingPlugins.installs; + if (!isObject(existingInstalls) || Object.keys(existingInstalls).length === 0) { + return; + } + + const currentPlugins = config.plugins; + if (!isObject(currentPlugins.installs)) { + currentPlugins.installs = {}; + } + Object.assign(currentPlugins.installs, existingInstalls); +} + +function hasPluginInstall(config: JsonObject, pluginId: string): boolean { + const plugins = config.plugins; + if (!isObject(plugins)) { + return false; + } + const installs = plugins.installs; + return isObject(installs) && pluginId in installs; +} + +function readJsonFile(pathValue: string): unknown { + return JSON.parse(readFileSync(pathValue, "utf-8")); +} + +function looksLikeWechatPluginMetadata(metadata: unknown, pathValue: string): boolean { + return ( + isObject(metadata) && + (metadata.id === "openclaw-weixin" || + metadata.name === "@tencent-weixin/openclaw-weixin" || + pathValue.toLowerCase().includes("openclaw-weixin")) + ); +} + +function hasInstalledWechatPluginMetadata(): boolean { + const stateDir = expandUser("~/.openclaw"); + const candidates = [ + join(stateDir, "extensions", "openclaw-weixin", "openclaw.plugin.json"), + join(stateDir, "extensions", "openclaw-weixin", "package.json"), + join(stateDir, "npm", "node_modules", "@tencent-weixin", "openclaw-weixin", "package.json"), + ]; + for (const candidate of candidates) { + try { + if (looksLikeWechatPluginMetadata(readJsonFile(candidate), candidate)) { + return true; + } + } catch { + // Keep scanning; stale metadata should not break config generation. + } + } + + const extensionsDir = join(stateDir, "extensions"); + if (!existsSync(extensionsDir)) { + return false; + } + + const ignoredDirs = new Set(["node_modules", "plugin-runtime-deps", ".git"]); + const stack = [extensionsDir]; + while (stack.length > 0) { + const dir = stack.pop() as string; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!ignoredDirs.has(entry.name)) { + stack.push(join(dir, entry.name)); + } + continue; + } + if (!entry.isFile() || !["openclaw.plugin.json", "package.json"].includes(entry.name)) { + continue; + } + const pathValue = join(dir, entry.name); + try { + if (looksLikeWechatPluginMetadata(readJsonFile(pathValue), pathValue)) { + return true; + } + } catch { + // Keep scanning; corrupt package metadata is ignored like the Python path. + } + } + } + return false; +} + +function hasPreinstalledWechatPluginSignal(): boolean { + return ["1", "true", "yes", "on"].includes( + (process.env.NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED || "").trim().toLowerCase(), + ); +} + +function seedWechatAccountsIfAvailable(config: JsonObject): void { + if ( + !hasPluginInstall(config, "openclaw-weixin") && + !hasInstalledWechatPluginMetadata() && + !hasPreinstalledWechatPluginSignal() + ) { + return; + } + + const seedScript = resolve(SCRIPT_DIR, "seed-wechat-accounts.py"); + const result = spawnSync("python3", [seedScript], { + stdio: "inherit", + env: process.env, + }); + if (result.error) { + throw result.error; + } + if (result.status !== null && result.status !== 0) { + process.exit(result.status); + } + if (result.signal) { + throw new Error(`${seedScript} terminated with signal ${result.signal}`); + } +} + +function main(): void { + const config = buildConfig(); + const configPath = expandUser("~/.openclaw/openclaw.json"); + preserveExistingPluginInstalls(config, configPath); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(config, null, 2)); + chmodSync(configPath, 0o600); + seedWechatAccountsIfAvailable(config); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/seed-wechat-accounts.py b/scripts/seed-wechat-accounts.py index 002316c50f..f4e4782784 100755 --- a/scripts/seed-wechat-accounts.py +++ b/scripts/seed-wechat-accounts.py @@ -22,7 +22,7 @@ # disabled and the bridge won't start, even if the per-account state files # above exist. The patch also restores the openclaw-weixin plugin registry and # load path because later OpenClaw config rewrites can drop them while leaving -# the pre-installed extension files in place. generate-openclaw-config.py +# the pre-installed extension files in place. generate-openclaw-config.ts # invokes this only after the base image's installed plugin metadata, install # registry, or preinstalled-plugin signal proves OpenClaw knows the WeChat # channel id. @@ -240,7 +240,7 @@ def _patch_openclaw_config(account_id: str) -> None: to decide which accounts to start at boot.""" cfg_path = _state_dir() / "openclaw.json" if not cfg_path.exists(): - # generate-openclaw-config.py runs before us and is responsible for + # generate-openclaw-config.ts runs before us and is responsible for # producing openclaw.json. If it's missing, something else broke; bail # without inventing a config. print( @@ -348,7 +348,7 @@ def main() -> int: # Empty accountId is the expected state when the operator did not go # through a host-side QR login (e.g. wechat channel never picked) — # no-op silently instead of warning, since this script now runs on - # every build from generate-openclaw-config.py. + # every build from generate-openclaw-config.ts. if not account_id: return 0 diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index a19ac70e03..90b59720d3 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -130,8 +130,8 @@ function stageOptimizedSandboxBuildContext( ); // OpenClaw config generator extracted in #2449 (fixed in #2565) fs.copyFileSync( - path.join(rootDir, "scripts", "generate-openclaw-config.py"), - path.join(stagedScriptsDir, "generate-openclaw-config.py"), + path.join(rootDir, "scripts", "generate-openclaw-config.ts"), + path.join(stagedScriptsDir, "generate-openclaw-config.ts"), ); // WeChat-account seed for the @tencent-weixin/openclaw-weixin plugin — // runs at image build time when WeChat is enabled to skip the upstream diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index d535744701..54c93f3d21 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -2,8 +2,8 @@ // 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 +// Functional tests for scripts/generate-openclaw-config.ts. +// Runs the actual TypeScript script with controlled env vars and asserts on // the generated openclaw.json output. import { describe, it, expect, beforeEach, afterEach } from "vitest"; @@ -12,7 +12,8 @@ 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"); +const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.ts"); +const SCRIPT_ARGS = ["--experimental-strip-types", SCRIPT_PATH]; /** Minimal env vars required for a valid config generation run. */ const BASE_ENV: Record = { @@ -40,7 +41,7 @@ function runConfigScriptRaw(envOverrides: Record = {}) { ...envOverrides, HOME: tmpDir, }; - const result = spawnSync("python3", [SCRIPT_PATH], { + const result = spawnSync("node", SCRIPT_ARGS, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env, @@ -116,7 +117,7 @@ afterEach(() => { // ═══════════════════════════════════════════════════════════════════ // Phase 1: Extraction — behavior-preserving tests // ═══════════════════════════════════════════════════════════════════ -describe("generate-openclaw-config.py: config generation", () => { +describe("generate-openclaw-config.ts: config generation", () => { it("generates valid JSON with minimal env vars", () => { const config = runConfigScript(); expect(config).toBeDefined(); @@ -145,6 +146,11 @@ describe("generate-openclaw-config.py: config generation", () => { expect(config.gateway.controlUi.allowInsecureAuth).toBe(false); }); + it("falls back to text input when NEMOCLAW_INFERENCE_INPUTS is empty", () => { + const config = runConfigScript({ NEMOCLAW_INFERENCE_INPUTS: "" }); + expect(config.models.providers["test-provider"].models[0].input).toEqual(["text"]); + }); + it("includes non-loopback origin in allowedOrigins", () => { const config = runConfigScript({ CHAT_UI_URL: "https://nemoclaw0-xxx.brevlab.com:18789", @@ -1156,7 +1162,7 @@ describe("generate-openclaw-config.py: config generation", () => { // ═══════════════════════════════════════════════════════════════════ // Phase 2: Auto-disable device auth for non-loopback URLs // ═══════════════════════════════════════════════════════════════════ -describe("generate-openclaw-config.py: non-loopback auto-disable device auth", () => { +describe("generate-openclaw-config.ts: 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", @@ -1203,7 +1209,7 @@ describe("generate-openclaw-config.py: non-loopback auto-disable device auth", ( }); }); -describe("generate-openclaw-config.py: empty-string env vars fall back to defaults", () => { +describe("generate-openclaw-config.ts: 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); @@ -1239,7 +1245,7 @@ describe("generate-openclaw-config.py: empty-string env vars fall back to defaul }); }); -describe("generate-openclaw-config.py: numeric env var validation", () => { +describe("generate-openclaw-config.ts: numeric env var validation", () => { function runCapturingStderr(envOverrides: Record): { config: any; stderr: string; @@ -1250,7 +1256,7 @@ describe("generate-openclaw-config.py: numeric env var validation", () => { ...envOverrides, HOME: tmpDir, }; - const result = spawnSync("python3", [SCRIPT_PATH], { + const result = spawnSync("node", SCRIPT_ARGS, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env, @@ -1308,13 +1314,13 @@ describe("generate-openclaw-config.py: numeric env var validation", () => { expect(stderr).toMatch(/NEMOCLAW_MAX_TOKENS must be a positive integer/); }); - it("skips NEMOCLAW_CONTEXT_WINDOW that exceeds Python's int-string digit limit", () => { + it("skips NEMOCLAW_CONTEXT_WINDOW that exceeds the safe integer guard", () => { const { config, stderr } = runCapturingStderr({ NEMOCLAW_CONTEXT_WINDOW: "9".repeat(10000) }); expect(config.models.providers["test-provider"].models[0].contextWindow).toBe(131072); expect(stderr).toMatch(/NEMOCLAW_CONTEXT_WINDOW must be a positive integer/); }); - it("skips NEMOCLAW_MAX_TOKENS that exceeds Python's int-string digit limit", () => { + it("skips NEMOCLAW_MAX_TOKENS that exceeds the safe integer guard", () => { const { config, stderr } = runCapturingStderr({ NEMOCLAW_MAX_TOKENS: "9".repeat(10000) }); expect(config.models.providers["test-provider"].models[0].maxTokens).toBe(4096); expect(stderr).toMatch(/NEMOCLAW_MAX_TOKENS must be a positive integer/); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index a31a21fe91..77b559cef7 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -73,7 +73,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "nemoclaw-start.sh")); writeFixture(path.join("scripts", "codex-acp-wrapper.sh")); writeFixture(path.join("scripts", "lib", "sandbox-init.sh")); - writeFixture(path.join("scripts", "generate-openclaw-config.py")); + writeFixture(path.join("scripts", "generate-openclaw-config.ts")); writeFixture(path.join("scripts", "seed-wechat-accounts.py")); writeFixture(path.join("scripts", "patch-openclaw-tool-catalog.js")); writeFixture(path.join("scripts", "patch-openclaw-chat-send.js")); @@ -243,7 +243,7 @@ describe("sandbox build context staging", () => { ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "nemoclaw-start.sh"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "codex-acp-wrapper.sh"))).toBe(true); - expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.py"))).toBe( + expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.ts"))).toBe( true, ); expect(fs.existsSync(path.join(buildCtx, "scripts", "seed-wechat-accounts.py"))).toBe(true); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index e0ab6b2afa..ae95cde19a 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -722,7 +722,7 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () path.join(localBin, "nemoclaw-start"), path.join(localBin, "nemoclaw-codex-acp"), path.join(localLib, "sandbox-init.sh"), - path.join(localLib, "generate-openclaw-config.py"), + path.join(localLib, "generate-openclaw-config.ts"), path.join(localLib, "seed-wechat-accounts.py"), path.join(localLib, "ws-proxy-fix.js"), pluginFile, @@ -750,7 +750,7 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () expect(result.status, result.stderr).toBe(0); const generatorMode = ( - fs.statSync(path.join(localLib, "generate-openclaw-config.py")).mode & 0o777 + fs.statSync(path.join(localLib, "generate-openclaw-config.ts")).mode & 0o777 ).toString(8); const pluginDirMode = (fs.statSync(pluginDir).mode & 0o777).toString(8); const pluginMode = (fs.statSync(pluginFile).mode & 0o777).toString(8); diff --git a/test/security-c2-dockerfile-injection.test.ts b/test/security-c2-dockerfile-injection.test.ts index f5d46c0fae..278b7a9098 100644 --- a/test/security-c2-dockerfile-injection.test.ts +++ b/test/security-c2-dockerfile-injection.test.ts @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Security regression test: C-2 — CHAT_UI_URL Python code injection in Dockerfile. +// Security regression test: C-2 — CHAT_UI_URL source injection in Dockerfile. // // The vulnerable pattern interpolates Docker build-args directly into a -// python3 -c source string. A single-quote in the value closes the Python +// generated source string. A single-quote in the value closes the JavaScript // string literal and allows arbitrary code execution at image build time. // -// The fixed pattern reads values via os.environ (data, not source code). +// The fixed pattern reads values via process.env (data, not source code). import { describe, it, expect } from "vitest"; import fs from "node:fs"; @@ -17,8 +17,8 @@ import { spawnSync } from "node:child_process"; const DOCKERFILE = path.join(import.meta.dirname, "..", "Dockerfile"); -function runPython(src: string, env: Record = {}) { - return spawnSync("python3", ["-c", src], { +function runNode(src: string, env: Record = {}) { + return spawnSync("node", ["-e", src], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...env }, @@ -29,49 +29,43 @@ function runPython(src: string, env: Record = {}) { // Simulate what Docker ARG substitution produces (the VULNERABLE pattern) function vulnerableSource(chatUiUrlValue: string): string { return ( - "import json, os, secrets; " + - "from urllib.parse import urlparse; " + - `chat_ui_url = '${chatUiUrlValue}'; ` + - "parsed = urlparse(chat_ui_url); " + - "print(repr(chat_ui_url))" + `const chatUiUrl = '${chatUiUrlValue}'; ` + + "console.log(JSON.stringify(chatUiUrl))" ); } // Simulate the FIXED pattern (env var, no source interpolation) function fixedSource(): string { return ( - "import json, os, secrets; " + - "from urllib.parse import urlparse; " + - "chat_ui_url = os.environ['CHAT_UI_URL']; " + - "parsed = urlparse(chat_ui_url); " + - "print(repr(chat_ui_url))" + "const chatUiUrl = process.env.CHAT_UI_URL; " + + "console.log(JSON.stringify(chatUiUrl))" ); } // ═══════════════════════════════════════════════════════════════════ // 1. PoC — vulnerable pattern allows code injection // ═══════════════════════════════════════════════════════════════════ -describe("C-2 PoC: vulnerable pattern (ARG interpolation into python3 -c)", () => { +describe("C-2 PoC: vulnerable pattern (ARG interpolation into source)", () => { it("benign URL works in the vulnerable pattern (baseline)", () => { const src = vulnerableSource("http://127.0.0.1:18789"); - const result = runPython(src); + const result = runNode(src); expect(result.status).toBe(0); expect(result.stdout.includes("127.0.0.1")).toBeTruthy(); }); it("single-quote in URL causes SyntaxError", () => { const src = vulnerableSource("http://x'.evil.com"); - const result = runPython(src); + const result = runNode(src); expect(result.status).not.toBe(0); expect(result.stderr.includes("SyntaxError")).toBeTruthy(); }); - it("injection payload writes canary file — arbitrary Python executes", () => { + it("injection payload writes canary file — arbitrary JavaScript executes", () => { const canary = path.join(os.tmpdir(), `nemoclaw-c2-poc-${Date.now()}`); try { - const payload = `http://x'; open('${canary}','w').write('PWNED') #`; + const payload = `http://x'; require('node:fs').writeFileSync('${canary}','PWNED') //`; const src = vulnerableSource(payload); - runPython(src); + runNode(src); expect(fs.existsSync(canary)).toBeTruthy(); expect(fs.readFileSync(canary, "utf-8")).toBe("PWNED"); @@ -88,15 +82,15 @@ describe("C-2 PoC: vulnerable pattern (ARG interpolation into python3 -c)", () = // ═══════════════════════════════════════════════════════════════════ // 2. Fix verification — env var pattern treats all payloads as data // ═══════════════════════════════════════════════════════════════════ -describe("C-2 fix: env var pattern (os.environ) is safe", () => { +describe("C-2 fix: env var pattern (process.env) is safe", () => { it("benign URL works through env var", () => { - const result = runPython(fixedSource(), { CHAT_UI_URL: "http://127.0.0.1:18789" }); + const result = runNode(fixedSource(), { CHAT_UI_URL: "http://127.0.0.1:18789" }); expect(result.status).toBe(0); expect(result.stdout.includes("127.0.0.1")).toBeTruthy(); }); it("single-quote in URL is treated as data, not a code boundary", () => { - const result = runPython(fixedSource(), { CHAT_UI_URL: "http://x'.evil.com" }); + const result = runNode(fixedSource(), { CHAT_UI_URL: "http://x'.evil.com" }); expect(result.status).toBe(0); expect(result.stdout.includes("x'.evil.com")).toBeTruthy(); }); @@ -104,8 +98,8 @@ describe("C-2 fix: env var pattern (os.environ) is safe", () => { it("injection payload does NOT execute — URL is inert data", () => { const canary = path.join(os.tmpdir(), `nemoclaw-c2-fixed-${Date.now()}`); try { - const payload = `http://x'; open('${canary}','w').write('PWNED') #`; - const result = runPython(fixedSource(), { CHAT_UI_URL: payload }); + const payload = `http://x'; require('node:fs').writeFileSync('${canary}','PWNED') //`; + const result = runNode(fixedSource(), { CHAT_UI_URL: payload }); expect(result.status).toBe(0); expect(fs.existsSync(canary)).toBe(false); @@ -120,7 +114,7 @@ describe("C-2 fix: env var pattern (os.environ) is safe", () => { it("semicolons and import statements in URL are literal data", () => { const dangerous = "http://x; import subprocess; subprocess.run(['id'])"; - const result = runPython(fixedSource(), { CHAT_UI_URL: dangerous }); + const result = runNode(fixedSource(), { CHAT_UI_URL: dangerous }); // The URL is treated as data — urlparse may or may not raise, but // the key property is that no code injection occurs. Check stdout or stderr // does NOT contain evidence of os.system/subprocess execution. @@ -133,7 +127,7 @@ describe("C-2 fix: env var pattern (os.environ) is safe", () => { // 4. Gateway auth hardening — no hardcoded insecure defaults (#117) // ═══════════════════════════════════════════════════════════════════ describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth defaults", () => { - it("NEMOCLAW_DISABLE_DEVICE_AUTH is promoted to ENV before the Python RUN layer", () => { + it("NEMOCLAW_DISABLE_DEVICE_AUTH is promoted to ENV before the config generator RUN layer", () => { const src = fs.readFileSync(DOCKERFILE, "utf-8"); const lines = src.split("\n"); let promoted = false; @@ -154,7 +148,7 @@ describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth def inEnvBlock = false; } if ( - /^\s*RUN\b.*python3\s+\/usr\/local\/lib\/nemoclaw\/generate-openclaw-config\.py\b/.test( + /^\s*RUN\b.*node\s+--experimental-strip-types\s+\/usr\/local\/lib\/nemoclaw\/generate-openclaw-config\.ts\b/.test( line, ) ) { diff --git a/test/seed-wechat-accounts.test.ts b/test/seed-wechat-accounts.test.ts index 1c3fe83fa9..c8f9763fdf 100644 --- a/test/seed-wechat-accounts.test.ts +++ b/test/seed-wechat-accounts.test.ts @@ -101,7 +101,7 @@ afterEach(() => { describe("seed-wechat-accounts.py: gating", () => { it("no-ops silently when NEMOCLAW_WECHAT_CONFIG_B64 is unset", () => { - // The script now runs unconditionally from generate-openclaw-config.py + // The script now runs unconditionally from generate-openclaw-config.ts // on every build, so the "no host-side QR login was performed" path is // the common case and must stay quiet — no stderr noise, no on-disk // state under the plugin state dir. @@ -278,7 +278,7 @@ describe("seed-wechat-accounts.py: openclaw.json patching (channels.openclaw-wei it("preserves existing unrelated keys in openclaw.json", () => { // The patch must merge into the existing config — clobbering gateway or - // other channels would break everything else generate-openclaw-config.py + // other channels would break everything else generate-openclaw-config.ts // wrote moments earlier. writeOpenclawConfig({ gateway: { port: 9999, marker: "keep-me" }, @@ -297,7 +297,7 @@ describe("seed-wechat-accounts.py: openclaw.json patching (channels.openclaw-wei it("restores plugin registration and channel block after a later OpenClaw config rewrite drops them", () => { // The Dockerfile invokes this seed script again after OpenClaw doctor and // plugin installation because those commands can rewrite openclaw.json - // after generate-openclaw-config.py first runs. Re-running the seed must + // after generate-openclaw-config.ts first runs. Re-running the seed must // be enough to put the upstream WeChat plugin and channel registration // back; otherwise the gateway rejects channels.openclaw-weixin as an // unknown channel id at startup. @@ -398,7 +398,7 @@ describe("seed-wechat-accounts.py: openclaw.json patching (channels.openclaw-wei }); it("bails (and warns) when openclaw.json is missing — does not invent a config", () => { - // generate-openclaw-config.py runs first and is responsible for producing + // generate-openclaw-config.ts runs first and is responsible for producing // openclaw.json. If it failed silently, we'd rather print a warning than // create a half-formed file from this script's narrow vantage point. const result = runSeed({ From d8a8a8602ca5b739bf8f5d682f19576936b2c4a0 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 30 May 2026 13:55:10 -0700 Subject: [PATCH 2/7] fix(scripts): address OpenClaw generator review feedback Signed-off-by: Carlos Villela --- scripts/generate-openclaw-config.ts | 74 ++++++++++++--------------- test/generate-openclaw-config.test.ts | 62 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 41 deletions(-) diff --git a/scripts/generate-openclaw-config.ts b/scripts/generate-openclaw-config.ts index cdb7cb25ba..6824fc8cfa 100755 --- a/scripts/generate-openclaw-config.ts +++ b/scripts/generate-openclaw-config.ts @@ -123,48 +123,27 @@ function validateDashboardPort(raw: string, envName: string): number { type ParsedUrl = { scheme: string; - netloc: string; hostname: string; port: number | null; + origin: string | null; }; function parseUrl(rawUrl: string): ParsedUrl { - const match = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)(?:[/?#].*)?$/i.exec(rawUrl); - if (!match) { - return { scheme: "", netloc: "", hostname: "", port: null }; - } - - const scheme = match[1]; - const netloc = match[2]; - let hostname = netloc; - let portText: string | null = null; - - if (netloc.startsWith("[")) { - const end = netloc.indexOf("]"); - if (end >= 0) { - hostname = netloc.slice(1, end); - const rest = netloc.slice(end + 1); - if (rest.startsWith(":")) { - portText = rest.slice(1); - } - } - } else { - const colon = netloc.lastIndexOf(":"); - if (colon >= 0 && netloc.indexOf(":") === colon) { - hostname = netloc.slice(0, colon); - portText = netloc.slice(colon + 1); - } - } - - let port: number | null = null; - if (portText !== null && /^\d+$/.test(portText)) { - const value = Number(portText); - if (value >= 0 && value <= 65535) { - port = value; - } + // Match browser URL semantics for CHAT_UI_URL security decisions. In + // particular, userinfo such as "localhost@remote" must not be treated as + // the effective host. + try { + const url = new URL(rawUrl); + const port = url.port ? Number(url.port) : null; + return { + scheme: url.protocol.replace(/:$/, ""), + hostname: url.hostname.toLowerCase(), + port: port !== null && Number.isSafeInteger(port) ? port : null, + origin: url.origin === "null" ? null : url.origin, + }; + } catch { + return { scheme: "", hostname: "", port: null, origin: null }; } - - return { scheme, netloc, hostname: hostname.toLowerCase(), port }; } function chatUiUrlPort(chatUiUrl: string): number | null { @@ -186,6 +165,13 @@ function resolveGatewayPort(env: Env, chatUiUrl: string): number { return chatUiUrlPort(chatUiUrl) || DEFAULT_DASHBOARD_PORT; } +function hostForOrigin(hostname: string): string { + if (hostname.startsWith("[") && hostname.endsWith("]")) { + return hostname; + } + return hostname.includes(":") ? `[${hostname}]` : hostname; +} + function registryRoots(env: Env): string[] { const roots: string[] = []; const explicit = env.NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR; @@ -630,7 +616,6 @@ function buildConfig(env: Env = process.env): JsonObject { const ch = String(channel); if (ch === "whatsapp") { channelConfig[ch] = { - enabled: true, accounts: { default: { enabled: true, healthMonitor: { enabled: false } }, }, @@ -679,7 +664,7 @@ function buildConfig(env: Env = process.env): JsonObject { slackAllowedChannels.map((channelId) => [channelId, { ...slackChannelConfig }]), ); } - channelConfig[ch] = { enabled: true, accounts: { default: account } }; + channelConfig[ch] = { accounts: { default: account } }; } if ( @@ -700,11 +685,10 @@ function buildConfig(env: Env = process.env): JsonObject { const normalizedUrl = normalizeUrlForParse(chatUiUrl); const parsed = parseUrl(normalizedUrl); const loopbackOrigin = `http://127.0.0.1:${gatewayPort}`; - const chatOrigin = - parsed.scheme && parsed.netloc ? `${parsed.scheme}://${parsed.netloc}` : loopbackOrigin; + const chatOrigin = parsed.origin || loopbackOrigin; const portlessOrigin = parsed.scheme && parsed.hostname && parsed.port !== null && !isLoopback(parsed.hostname) - ? `${parsed.scheme}://${parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname}` + ? `${parsed.scheme}://${hostForOrigin(parsed.hostname)}` : null; const origins = unique([loopbackOrigin, chatOrigin, portlessOrigin].filter(Boolean) as string[]); @@ -883,6 +867,14 @@ function hasInstalledWechatPluginMetadata(): boolean { const candidates = [ join(stateDir, "extensions", "openclaw-weixin", "openclaw.plugin.json"), join(stateDir, "extensions", "openclaw-weixin", "package.json"), + join( + stateDir, + "npm", + "node_modules", + "@tencent-weixin", + "openclaw-weixin", + "openclaw.plugin.json", + ), join(stateDir, "npm", "node_modules", "@tencent-weixin", "openclaw-weixin", "package.json"), ]; for (const candidate of candidates) { diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 54c93f3d21..4690c50290 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -81,6 +81,19 @@ function writeWeChatNpmPackageMetadata(manifest: Record) { fs.writeFileSync(path.join(pluginDir, "package.json"), JSON.stringify(manifest, null, 2)); } +function writeWeChatNpmPluginMetadata(manifest: Record) { + const pluginDir = path.join( + tmpDir, + ".openclaw", + "npm", + "node_modules", + "@tencent-weixin", + "openclaw-weixin", + ); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "openclaw.plugin.json"), JSON.stringify(manifest, null, 2)); +} + function wechatExtensionPath(stateDir = path.join(tmpDir, ".openclaw")) { return path.join(fs.realpathSync(stateDir), "extensions", "openclaw-weixin"); } @@ -131,6 +144,21 @@ describe("generate-openclaw-config.ts: config generation", () => { expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(false); }); + it("treats loopback-looking URL userinfo before a remote host as remote", () => { + const config = runConfigScript({ CHAT_UI_URL: "http://127.0.0.1:18789@evil.example" }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.gateway.controlUi.allowedOrigins).toContain("http://evil.example"); + expect(config.gateway.controlUi.allowedOrigins).not.toContain( + "http://127.0.0.1:18789@evil.example", + ); + }); + + it("treats localhost userinfo before a remote host as remote", () => { + const config = runConfigScript({ CHAT_UI_URL: "http://localhost@evil.example" }); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.gateway.controlUi.allowedOrigins).toContain("http://evil.example"); + }); + 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); @@ -238,6 +266,7 @@ describe("generate-openclaw-config.ts: config generation", () => { const channels = Buffer.from(JSON.stringify(["whatsapp"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); expect(config.channels.whatsapp).toBeDefined(); + expect(config.channels.whatsapp.enabled).toBeUndefined(); const account = config.channels.whatsapp.accounts.default; expect(account.enabled).toBe(true); expect(account.healthMonitor).toEqual({ enabled: false }); @@ -252,7 +281,9 @@ describe("generate-openclaw-config.ts: config generation", () => { expect(config.channels.telegram.accounts.default.botToken).toBe( "openshell:resolve:env:TELEGRAM_BOT_TOKEN", ); + expect(config.channels.telegram.enabled).toBeUndefined(); expect(config.channels.whatsapp.accounts.default.enabled).toBe(true); + expect(config.channels.whatsapp.enabled).toBeUndefined(); expect(config.channels.whatsapp.accounts.default.botToken).toBeUndefined(); }); @@ -407,6 +438,37 @@ describe("generate-openclaw-config.ts: config generation", () => { expect(fs.existsSync(wechatExtensionPath())).toBe(false); }); + it("uses the npm package path when installed WeChat plugin metadata exists without an extension dir", () => { + writeWeChatNpmPluginMetadata({ + id: "openclaw-weixin", + channelConfigs: { "vendor-weixin": {} }, + }); + + const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); + const wechatConfig = Buffer.from( + JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), + ).toString("base64"); + const config = runConfigScript({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, + }); + + expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ + source: "npm", + spec: "@tencent-weixin/openclaw-weixin@2.4.3", + installPath: wechatNpmPackagePath(), + }); + expect(config.plugins?.load?.paths).toEqual([wechatNpmPackagePath()]); + expect(config.channels?.["vendor-weixin"]?.accounts?.primary).toEqual({ + enabled: true, + }); + expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ + enabled: true, + }); + expect(config.channels?.wechat).toBeUndefined(); + expect(fs.existsSync(wechatExtensionPath())).toBe(false); + }); + it("seeds channels.openclaw-weixin when the Dockerfile marks the plugin preinstalled", () => { const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); const wechatConfig = Buffer.from( From 5cd42c70148edc9ad8a9ae00c5b33faf9829bd94 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 30 May 2026 14:25:22 -0700 Subject: [PATCH 3/7] test(scripts): expose OpenClaw generator unit coverage Signed-off-by: Carlos Villela --- scripts/generate-openclaw-config.ts | 22 ++++++++++++++-------- test/generate-openclaw-config.test.ts | 18 ++++++++++++++---- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/scripts/generate-openclaw-config.ts b/scripts/generate-openclaw-config.ts index 6824fc8cfa..b55017d2e0 100755 --- a/scripts/generate-openclaw-config.ts +++ b/scripts/generate-openclaw-config.ts @@ -31,7 +31,7 @@ import { writeFileSync, } from "node:fs"; import { dirname, isAbsolute, join, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { spawnSync } from "node:child_process"; type Env = Record; @@ -497,7 +497,7 @@ function decodeJsonEnv(env: Env, name: string, defaultValue: string): any { return JSON.parse(Buffer.from(raw, "base64").toString("utf-8")); } -function buildConfig(env: Env = process.env): JsonObject { +export function buildConfig(env: Env = process.env): JsonObject { const proxyHost = env.NEMOCLAW_PROXY_HOST || "10.200.0.1"; const proxyPort = env.NEMOCLAW_PROXY_PORT || "3128"; const proxyUrl = `http://${proxyHost}:${proxyPort}`; @@ -950,7 +950,7 @@ function seedWechatAccountsIfAvailable(config: JsonObject): void { } } -function main(): void { +export function main(): void { const config = buildConfig(); const configPath = expandUser("~/.openclaw/openclaw.json"); preserveExistingPluginInstalls(config, configPath); @@ -960,9 +960,15 @@ function main(): void { seedWechatAccountsIfAvailable(config); } -try { - main(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); +function isMainModule(): boolean { + return process.argv[1] ? import.meta.url === pathToFileURL(resolve(process.argv[1])).href : false; +} + +if (isMainModule()) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } } diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 4690c50290..15abca9c97 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -12,6 +12,8 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { buildConfig } from "../scripts/generate-openclaw-config.ts"; + const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.ts"); const SCRIPT_ARGS = ["--experimental-strip-types", SCRIPT_PATH]; @@ -62,6 +64,14 @@ function runConfigScript(envOverrides: Record = {}): any { return JSON.parse(fs.readFileSync(configPath, "utf-8")); } +function buildConfigDirect(envOverrides: Record = {}): any { + return buildConfig({ + ...BASE_ENV, + ...envOverrides, + HOME: tmpDir, + }); +} + function writeWeChatPluginMetadata(manifest: Record) { const pluginDir = path.join(tmpDir, ".openclaw", "extensions", "openclaw-weixin"); fs.mkdirSync(pluginDir, { recursive: true }); @@ -145,7 +155,7 @@ describe("generate-openclaw-config.ts: config generation", () => { }); it("treats loopback-looking URL userinfo before a remote host as remote", () => { - const config = runConfigScript({ CHAT_UI_URL: "http://127.0.0.1:18789@evil.example" }); + const config = buildConfigDirect({ CHAT_UI_URL: "http://127.0.0.1:18789@evil.example" }); expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); expect(config.gateway.controlUi.allowedOrigins).toContain("http://evil.example"); expect(config.gateway.controlUi.allowedOrigins).not.toContain( @@ -154,7 +164,7 @@ describe("generate-openclaw-config.ts: config generation", () => { }); it("treats localhost userinfo before a remote host as remote", () => { - const config = runConfigScript({ CHAT_UI_URL: "http://localhost@evil.example" }); + const config = buildConfigDirect({ CHAT_UI_URL: "http://localhost@evil.example" }); expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); expect(config.gateway.controlUi.allowedOrigins).toContain("http://evil.example"); }); @@ -264,7 +274,7 @@ describe("generate-openclaw-config.ts: config generation", () => { it("emits a tokenless WhatsApp config block for QR-paired channels", () => { const channels = Buffer.from(JSON.stringify(["whatsapp"])).toString("base64"); - const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); + const config = buildConfigDirect({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); expect(config.channels.whatsapp).toBeDefined(); expect(config.channels.whatsapp.enabled).toBeUndefined(); const account = config.channels.whatsapp.accounts.default; @@ -277,7 +287,7 @@ describe("generate-openclaw-config.ts: config generation", () => { it("keeps WhatsApp config alongside token-based channels in the same run", () => { const channels = Buffer.from(JSON.stringify(["telegram", "whatsapp"])).toString("base64"); - const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); + const config = buildConfigDirect({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); expect(config.channels.telegram.accounts.default.botToken).toBe( "openshell:resolve:env:TELEGRAM_BOT_TOKEN", ); From 82efa3a44a10bb2158b1d1d10978858689ae7fbe Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 30 May 2026 14:35:08 -0700 Subject: [PATCH 4/7] test(scripts): expand OpenClaw generator unit coverage Signed-off-by: Carlos Villela --- test/generate-openclaw-config.test.ts | 440 ++++++++++++++++++++------ 1 file changed, 341 insertions(+), 99 deletions(-) diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 15abca9c97..3158e53307 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -12,7 +12,7 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import { buildConfig } from "../scripts/generate-openclaw-config.ts"; +import { buildConfig, main } from "../scripts/generate-openclaw-config.ts"; const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.ts"); const SCRIPT_ARGS = ["--experimental-strip-types", SCRIPT_PATH]; @@ -52,7 +52,35 @@ function runConfigScriptRaw(envOverrides: Record = {}) { return result; } +function withConfigEnv(envOverrides: Record, fn: () => T): T { + const originalEnv = { ...process.env }; + const env: Record = { + PATH: process.env.PATH || "/usr/bin:/bin", + ...BASE_ENV, + ...envOverrides, + HOME: tmpDir, + }; + try { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + Object.assign(process.env, env); + return fn(); + } finally { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + Object.assign(process.env, originalEnv); + } +} + function runConfigScript(envOverrides: Record = {}): any { + withConfigEnv(envOverrides, () => main()); + const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); + return JSON.parse(fs.readFileSync(configPath, "utf-8")); +} + +function runConfigSubprocess(envOverrides: Record = {}): any { const result = runConfigScriptRaw(envOverrides); if (result.status !== 0) { throw new Error( @@ -65,11 +93,24 @@ function runConfigScript(envOverrides: Record = {}): any { } function buildConfigDirect(envOverrides: Record = {}): any { - return buildConfig({ - ...BASE_ENV, - ...envOverrides, - HOME: tmpDir, - }); + return withConfigEnv(envOverrides, () => buildConfig()); +} + +function expectBuildConfigError(envOverrides: Record, message: string | RegExp) { + expect(() => buildConfigDirect(envOverrides)).toThrow(message); +} + +function runCapturingConsoleError(fn: () => T): { result: T; stderr: string } { + const original = console.error; + const messages: string[] = []; + console.error = (...args: unknown[]) => { + messages.push(args.map(String).join(" ")); + }; + try { + return { result: fn(), stderr: messages.join("\n") }; + } finally { + console.error = original; + } } function writeWeChatPluginMetadata(manifest: Record) { @@ -149,6 +190,12 @@ describe("generate-openclaw-config.ts: config generation", () => { expect(config.agents).toBeDefined(); }); + it("runs as a node --experimental-strip-types executable", () => { + const config = runConfigSubprocess(); + expect(config.gateway).toBeDefined(); + expect(config.models).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); @@ -223,10 +270,34 @@ describe("generate-openclaw-config.ts: config generation", () => { }); it("rejects an invalid NEMOCLAW_DASHBOARD_PORT", () => { - const result = runConfigScriptRaw({ NEMOCLAW_DASHBOARD_PORT: "18790x" }); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("NEMOCLAW_DASHBOARD_PORT"); - expect(result.stderr).toContain("1024 and 65535"); + expectBuildConfigError( + { NEMOCLAW_DASHBOARD_PORT: "18790x" }, + /NEMOCLAW_DASHBOARD_PORT.*1024 and 65535/, + ); + }); + + it("rejects an out-of-range NEMOCLAW_DASHBOARD_PORT", () => { + expectBuildConfigError( + { NEMOCLAW_DASHBOARD_PORT: "80" }, + /NEMOCLAW_DASHBOARD_PORT.*1024 and 65535/, + ); + }); + + it("falls back to the default gateway port when CHAT_UI_URL uses a reserved port", () => { + const config = buildConfigDirect({ CHAT_UI_URL: "http://127.0.0.1:81" }); + expect(config.gateway.port).toBe(18789); + expect(config.gateway.controlUi.allowedOrigins).toEqual([ + "http://127.0.0.1:18789", + "http://127.0.0.1:81", + ]); + }); + + it("normalizes schemeless CHAT_UI_URL values before parsing", () => { + const config = buildConfigDirect({ CHAT_UI_URL: "remote.example:18790" }); + expect(config.gateway.port).toBe(18790); + expect(config.gateway.controlUi.dangerouslyDisableDeviceAuth).toBe(true); + expect(config.gateway.controlUi.allowedOrigins).toContain("http://remote.example:18790"); + expect(config.gateway.controlUi.allowedOrigins).toContain("http://remote.example"); }); it("includes portless origin for reverse-proxy access (Fixes #3000)", () => { @@ -328,6 +399,18 @@ describe("generate-openclaw-config.ts: config generation", () => { expect(config.channels.telegram.groups).toBeUndefined(); }); + it("emits Discord guild allowlist config when guilds are provided", () => { + const channels = Buffer.from(JSON.stringify(["discord"])).toString("base64"); + const guilds = { "1234567890": { enabled: true, requireMention: true } }; + const config = buildConfigDirect({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_DISCORD_GUILDS_B64: Buffer.from(JSON.stringify(guilds)).toString("base64"), + }); + + expect(config.channels.discord.groupPolicy).toBe("allowlist"); + expect(config.channels.discord.guilds).toEqual(guilds); + }); + it("does not seed channels.openclaw-weixin before the base plugin install registry exists", () => { const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); const wechatConfig = Buffer.from( @@ -343,6 +426,29 @@ describe("generate-openclaw-config.ts: config generation", () => { expect(config.channels?.wechat).toBeUndefined(); }); + it("detects installed WeChat metadata in nested extension directories", () => { + const pluginDir = path.join(tmpDir, ".openclaw", "extensions", "vendor", "openclaw-weixin"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.mkdirSync(path.join(tmpDir, ".openclaw", "extensions", "node_modules"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ name: "@tencent-weixin/openclaw-weixin" }), + ); + + const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); + const wechatConfig = Buffer.from( + JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), + ).toString("base64"); + const config = runConfigScript({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, + }); + + expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ + enabled: true, + }); + }); + it("seeds channels.openclaw-weixin when the base plugin install registry exists", () => { const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); const installEntry = { @@ -531,6 +637,18 @@ describe("generate-openclaw-config.ts: config generation", () => { expect(config.plugins?.entries?.["openclaw-weixin"]?.enabled).toBe(true); }); + it("ignores malformed existing plugin install registries while regenerating config", () => { + const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + + for (const existing of [null, { plugins: null }, { plugins: { installs: {} } }]) { + fs.writeFileSync(configPath, JSON.stringify(existing)); + const config = runConfigScript(); + expect(config.plugins?.entries?.["openclaw-weixin"]?.enabled).toBe(true); + expect(config.plugins?.installs).toBeUndefined(); + } + }); + it("emits canonical placeholders and proxy routing for non-Slack channels", () => { const channels = Buffer.from(JSON.stringify(["telegram", "discord"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); @@ -719,6 +837,13 @@ describe("generate-openclaw-config.ts: config generation", () => { expect(config.agents.defaults.timeoutSeconds).toBe(300); }); + it("rejects invalid agent timeout values", () => { + expectBuildConfigError( + { NEMOCLAW_AGENT_TIMEOUT: "forever" }, + "NEMOCLAW_AGENT_TIMEOUT must be a positive integer", + ); + }); + it("omits heartbeat when NEMOCLAW_AGENT_HEARTBEAT_EVERY is unset", () => { const config = runConfigScript(); expect(config.agents.defaults.heartbeat).toBeUndefined(); @@ -742,12 +867,11 @@ describe("generate-openclaw-config.ts: config generation", () => { }); it("rejects malformed heartbeat values, preserves OpenClaw default, and warns on stderr", () => { - const result = runConfigScriptRaw({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "5 minutes" }); - expect(result.status).toBe(0); - const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); - const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + const { result: config, stderr } = runCapturingConsoleError(() => + buildConfigDirect({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "5 minutes" }), + ); expect(config.agents.defaults.heartbeat).toBeUndefined(); - expect(result.stderr).toMatch( + expect(stderr).toMatch( /\[SECURITY\] NEMOCLAW_AGENT_HEARTBEAT_EVERY must match \^\\d\+\(s\|m\|h\)\$, got "5 minutes"/, ); }); @@ -830,6 +954,13 @@ describe("generate-openclaw-config.ts: config generation", () => { }); }); + it("rejects inference compat blobs that decode to non-object JSON", () => { + expectBuildConfigError( + { NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from('"not-an-object"').toString("base64") }, + "NEMOCLAW_INFERENCE_COMPAT_B64 must decode to a JSON object or null", + ); + }); + // #2747: Ollama's OpenAI-compatible streaming API omits the usage chunk // unless `stream_options.include_usage` is set on the request. OpenClaw // gates that on `model.compat.supportsUsageInStreaming`. NemoClaw routes @@ -936,12 +1067,10 @@ describe("generate-openclaw-config.ts: config generation", () => { }, ); - const result = runConfigScriptRaw({ - NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir, - }); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("field 'agent' is required"); + expectBuildConfigError( + { NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir }, + "field 'agent' is required", + ); const unknownRegistryDir = writeRegistryManifest( blueprintDir, @@ -956,22 +1085,16 @@ describe("generate-openclaw-config.ts: config generation", () => { ); fs.rmSync(path.join(blueprintDir, "model-specific-setup", "openclaw", "missing-agent.json")); - const unknownResult = runConfigScriptRaw({ - NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: unknownRegistryDir, - }); - - expect(unknownResult.status).not.toBe(0); - expect(unknownResult.stderr).toContain("unknown agent 'sidecar'"); + expectBuildConfigError( + { NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: unknownRegistryDir }, + "unknown agent 'sidecar'", + ); }, 20_000); it("rejects empty match objects and invalid explicit registry overrides", () => { const missingRegistry = path.join(tmpDir, "missing-registry"); - const missingRegistryResult = runConfigScriptRaw({ - NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: missingRegistry, - }); - - expect(missingRegistryResult.status).not.toBe(0); - expect(missingRegistryResult.stderr).toContain( + expectBuildConfigError( + { NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: missingRegistry }, "NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR must point to an existing directory", ); @@ -988,12 +1111,145 @@ describe("generate-openclaw-config.ts: config generation", () => { }, ); - const emptyMatchResult = runConfigScriptRaw({ - NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir, - }); + expectBuildConfigError( + { NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir }, + "field 'match' must be a non-empty object", + ); + }); - expect(emptyMatchResult.status).not.toBe(0); - expect(emptyMatchResult.stderr).toContain("field 'match' must be a non-empty object"); + it("rejects malformed model-specific setup manifest fields independently", () => { + const validManifest = { + id: "fixture", + agent: "openclaw", + description: "Fixture manifest", + match: { modelIds: ["test-model"] }, + effects: { openclawCompat: {} }, + }; + const cases = [ + { + name: "non-object root", + manifest: null, + message: "manifest must be a JSON object", + }, + { + name: "missing id", + manifest: { ...validManifest, id: "" }, + message: "field 'id' must be a non-empty string", + }, + { + name: "missing description", + manifest: { ...validManifest, description: "" }, + message: "field 'description' must be a non-empty string", + }, + { + name: "non-object match", + manifest: { ...validManifest, match: null }, + message: "field 'match' must be an object", + }, + { + name: "unknown match key", + manifest: { ...validManifest, match: { modelIds: ["test-model"], family: "kimi" } }, + message: "unknown match keys: family", + }, + { + name: "empty modelIds", + manifest: { ...validManifest, match: { modelIds: [] } }, + message: "match.modelIds must be a non-empty string array", + }, + { + name: "empty providerKey", + manifest: { ...validManifest, match: { providerKey: "" } }, + message: "match.providerKey must be a non-empty string", + }, + { + name: "missing effects", + manifest: { ...validManifest, effects: null }, + message: "field 'effects' must be a non-empty object", + }, + { + name: "non-object openclawCompat", + manifest: { ...validManifest, effects: { openclawCompat: false } }, + message: "effects.openclawCompat must be an object", + }, + { + name: "non-object openclawTools", + manifest: { ...validManifest, effects: { openclawTools: false } }, + message: "effects.openclawTools must be an object", + }, + { + name: "unknown openclawTools key", + manifest: { ...validManifest, effects: { openclawTools: { webSearch: true } } }, + message: "unknown effects.openclawTools keys: webSearch", + }, + { + name: "non-array openclawPlugins", + manifest: { ...validManifest, effects: { openclawPlugins: {} } }, + message: "effects.openclawPlugins must be an array", + }, + { + name: "non-object openclaw plugin", + manifest: { ...validManifest, effects: { openclawPlugins: ["plugin"] } }, + message: "effects.openclawPlugins[0] must be an object", + }, + { + name: "missing openclaw plugin id", + manifest: { + ...validManifest, + effects: { + openclawPlugins: [ + { + id: "", + path: "openclaw-plugins/fixture", + loadPath: "/usr/local/share/nemoclaw/openclaw-plugins/fixture", + }, + ], + }, + }, + message: "effects.openclawPlugins[0].id must be a non-empty string", + }, + { + name: "absolute openclaw plugin source path", + manifest: { + ...validManifest, + effects: { + openclawPlugins: [ + { + id: "fixture-plugin", + path: "/tmp/plugin", + loadPath: "/usr/local/share/nemoclaw/openclaw-plugins/fixture", + }, + ], + }, + }, + message: "must be relative to nemoclaw-blueprint", + }, + { + name: "parent-relative openclaw plugin source path", + manifest: { + ...validManifest, + effects: { + openclawPlugins: [ + { + id: "fixture-plugin", + path: "../plugin", + loadPath: "/usr/local/share/nemoclaw/openclaw-plugins/fixture", + }, + ], + }, + }, + message: "must be relative to nemoclaw-blueprint", + }, + ]; + + for (const testCase of cases) { + const blueprintDir = path.join(tmpDir, `fixture-blueprint-${testCase.name.replaceAll(" ", "-")}`); + const registryDir = writeRegistryManifest( + blueprintDir, + "openclaw/manifest.json", + testCase.manifest as any, + ); + expectBuildConfigError({ NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir }, testCase.message); + } }); it("rejects unknown OpenClaw effect keys and missing plugin source paths", () => { @@ -1010,12 +1266,10 @@ describe("generate-openclaw-config.ts: config generation", () => { }, ); - const result = runConfigScriptRaw({ - NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir, - }); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("unknown effects for agent 'openclaw': hermesCompat"); + expectBuildConfigError( + { NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir }, + "unknown effects for agent 'openclaw': hermesCompat", + ); fs.rmSync(path.join(blueprintDir, "model-specific-setup", "openclaw", "bad-effect.json")); const missingPluginRegistryDir = writeRegistryManifest( @@ -1038,12 +1292,10 @@ describe("generate-openclaw-config.ts: config generation", () => { }, ); - const missingPluginResult = runConfigScriptRaw({ - NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: missingPluginRegistryDir, - }); - - expect(missingPluginResult.status).not.toBe(0); - expect(missingPluginResult.stderr).toContain("path does not exist"); + expectBuildConfigError( + { NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: missingPluginRegistryDir }, + "path does not exist", + ); fs.rmSync(path.join(blueprintDir, "model-specific-setup", "openclaw", "missing-plugin.json")); const badToolRegistryDir = writeRegistryManifest( @@ -1058,12 +1310,10 @@ describe("generate-openclaw-config.ts: config generation", () => { }, ); - const badToolResult = runConfigScriptRaw({ - NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: badToolRegistryDir, - }); - - expect(badToolResult.status).not.toBe(0); - expect(badToolResult.stderr).toContain("effects.openclawTools.toolSearch must be a boolean"); + expectBuildConfigError( + { NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: badToolRegistryDir }, + "effects.openclawTools.toolSearch must be a boolean", + ); fs.rmSync(path.join(blueprintDir, "model-specific-setup", "openclaw", "bad-tool-effect.json")); fs.mkdirSync(path.join(blueprintDir, "openclaw-plugins", "fixture"), { recursive: true }); @@ -1087,12 +1337,8 @@ describe("generate-openclaw-config.ts: config generation", () => { }, ); - const badLoadPathResult = runConfigScriptRaw({ - NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: badLoadPathRegistryDir, - }); - - expect(badLoadPathResult.status).not.toBe(0); - expect(badLoadPathResult.stderr).toContain( + expectBuildConfigError( + { NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: badLoadPathRegistryDir }, "effects.openclawPlugins[0].loadPath must be " + "'/usr/local/share/nemoclaw/openclaw-plugins/fixture'", ); @@ -1117,21 +1363,41 @@ describe("generate-openclaw-config.ts: config generation", () => { }, ); - const conflictResult = runConfigScriptRaw({ - NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir, - NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from( - JSON.stringify({ supportsStore: false }), - ).toString("base64"), - }); - - expect(conflictResult.status).not.toBe(0); - expect(conflictResult.stderr).toContain( + expectBuildConfigError( + { + NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir, + NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from( + JSON.stringify({ supportsStore: false }), + ).toString("base64"), + }, "model-specific setup 'conflicting-compat' conflicts with inference compat key 'supportsStore'", ); fs.rmSync( path.join(blueprintDir, "model-specific-setup", "openclaw", "conflicting-compat.json"), ); + writeRegistryManifest(blueprintDir, "openclaw/tool-a.json", { + id: "tool-a", + agent: "openclaw", + description: "First tool override", + match: { modelIds: ["test-model"] }, + effects: { openclawTools: { toolSearch: false } }, + }); + writeRegistryManifest(blueprintDir, "openclaw/tool-b.json", { + id: "tool-b", + agent: "openclaw", + description: "Conflicting tool override", + match: { modelIds: ["test-model"] }, + effects: { openclawTools: { toolSearch: true } }, + }); + + expectBuildConfigError( + { NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir }, + "model-specific setup 'tool-b' conflicts with OpenClaw tools key 'toolSearch'", + ); + + fs.rmSync(path.join(blueprintDir, "model-specific-setup", "openclaw", "tool-a.json")); + fs.rmSync(path.join(blueprintDir, "model-specific-setup", "openclaw", "tool-b.json")); writeRegistryManifest(blueprintDir, "openclaw/plugin-a.json", { id: "plugin-a", agent: "openclaw", @@ -1163,12 +1429,8 @@ describe("generate-openclaw-config.ts: config generation", () => { }, }); - const duplicateResult = runConfigScriptRaw({ - NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir, - }); - - expect(duplicateResult.status).not.toBe(0); - expect(duplicateResult.stderr).toContain( + expectBuildConfigError( + { NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir }, "model-specific setup 'plugin-b' declares duplicate OpenClaw plugin 'fixture-plugin'", ); }); @@ -1322,28 +1584,8 @@ describe("generate-openclaw-config.ts: numeric env var validation", () => { config: any; stderr: string; } { - const env: Record = { - PATH: process.env.PATH || "/usr/bin:/bin", - ...BASE_ENV, - ...envOverrides, - HOME: tmpDir, - }; - const result = spawnSync("node", SCRIPT_ARGS, { - 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 { - config: JSON.parse(fs.readFileSync(configPath, "utf-8")), - stderr: result.stderr, - }; + const { result, stderr } = runCapturingConsoleError(() => buildConfigDirect(envOverrides)); + return { config: result, stderr }; } it("skips non-numeric NEMOCLAW_CONTEXT_WINDOW and falls back to the default", () => { From a58248a044437d271b4db3cc8997390b541c0e4c Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 30 May 2026 15:00:10 -0700 Subject: [PATCH 5/7] fix(scripts): run OpenClaw generator as ESM TypeScript Signed-off-by: Carlos Villela --- Dockerfile | 8 ++++---- ...law-config.ts => generate-openclaw-config.mts} | 0 scripts/seed-wechat-accounts.py | 6 +++--- src/lib/sandbox/build-context.ts | 4 ++-- test/generate-openclaw-config.test.ts | 14 +++++++------- test/sandbox-build-context.test.ts | 4 ++-- test/sandbox-provisioning.test.ts | 4 ++-- test/security-c2-dockerfile-injection.test.ts | 15 ++++++++------- test/seed-wechat-accounts.test.ts | 8 ++++---- 9 files changed, 32 insertions(+), 31 deletions(-) rename scripts/{generate-openclaw-config.ts => generate-openclaw-config.mts} (100%) diff --git a/Dockerfile b/Dockerfile index 12b36ec2a1..4027f13f9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -394,12 +394,12 @@ COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start # needs to read these files to install runtime preloads under /tmp. COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp -COPY scripts/generate-openclaw-config.ts /usr/local/lib/nemoclaw/generate-openclaw-config.ts +COPY scripts/generate-openclaw-config.mts /usr/local/lib/nemoclaw/generate-openclaw-config.mts COPY scripts/seed-wechat-accounts.py /usr/local/lib/nemoclaw/seed-wechat-accounts.py COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ /usr/local/lib/nemoclaw/sandbox-init.sh \ - /usr/local/lib/nemoclaw/generate-openclaw-config.ts \ + /usr/local/lib/nemoclaw/generate-openclaw-config.mts \ /usr/local/lib/nemoclaw/seed-wechat-accounts.py \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then find /usr/local/lib/nemoclaw/preloads -type f -name '*.js' -exec chmod 644 {} +; fi \ && chmod 755 /usr/local/share/nemoclaw \ @@ -533,14 +533,14 @@ USER sandbox # Build args (NEMOCLAW_MODEL, CHAT_UI_URL) customize per deployment. # # Generate openclaw.json from environment variables. Config generation logic -# lives in scripts/generate-openclaw-config.ts — see that file for the full +# lives in scripts/generate-openclaw-config.mts — see that file for the full # list of env vars and derivation rules. # # OpenClaw's managed proxy config activates process-wide HTTP_PROXY/HTTPS_PROXY # for child npm processes. During image build the OpenShell gateway is not # available at the runtime sandbox proxy address yet, so defer the final proxy # block until after build-time OpenClaw doctor/plugin commands complete. -RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 node --experimental-strip-types /usr/local/lib/nemoclaw/generate-openclaw-config.ts +RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 node --experimental-strip-types /usr/local/lib/nemoclaw/generate-openclaw-config.mts # hadolint ignore=DL3059,DL4006 RUN openclaw doctor --fix --non-interactive diff --git a/scripts/generate-openclaw-config.ts b/scripts/generate-openclaw-config.mts similarity index 100% rename from scripts/generate-openclaw-config.ts rename to scripts/generate-openclaw-config.mts diff --git a/scripts/seed-wechat-accounts.py b/scripts/seed-wechat-accounts.py index f4e4782784..c3e44f213b 100755 --- a/scripts/seed-wechat-accounts.py +++ b/scripts/seed-wechat-accounts.py @@ -22,7 +22,7 @@ # disabled and the bridge won't start, even if the per-account state files # above exist. The patch also restores the openclaw-weixin plugin registry and # load path because later OpenClaw config rewrites can drop them while leaving -# the pre-installed extension files in place. generate-openclaw-config.ts +# the pre-installed extension files in place. generate-openclaw-config.mts # invokes this only after the base image's installed plugin metadata, install # registry, or preinstalled-plugin signal proves OpenClaw knows the WeChat # channel id. @@ -240,7 +240,7 @@ def _patch_openclaw_config(account_id: str) -> None: to decide which accounts to start at boot.""" cfg_path = _state_dir() / "openclaw.json" if not cfg_path.exists(): - # generate-openclaw-config.ts runs before us and is responsible for + # generate-openclaw-config.mts runs before us and is responsible for # producing openclaw.json. If it's missing, something else broke; bail # without inventing a config. print( @@ -348,7 +348,7 @@ def main() -> int: # Empty accountId is the expected state when the operator did not go # through a host-side QR login (e.g. wechat channel never picked) — # no-op silently instead of warning, since this script now runs on - # every build from generate-openclaw-config.ts. + # every build from generate-openclaw-config.mts. if not account_id: return 0 diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 90b59720d3..a361f61bb7 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -130,8 +130,8 @@ function stageOptimizedSandboxBuildContext( ); // OpenClaw config generator extracted in #2449 (fixed in #2565) fs.copyFileSync( - path.join(rootDir, "scripts", "generate-openclaw-config.ts"), - path.join(stagedScriptsDir, "generate-openclaw-config.ts"), + path.join(rootDir, "scripts", "generate-openclaw-config.mts"), + path.join(stagedScriptsDir, "generate-openclaw-config.mts"), ); // WeChat-account seed for the @tencent-weixin/openclaw-weixin plugin — // runs at image build time when WeChat is enabled to skip the upstream diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 3158e53307..fddb0ae53b 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Functional tests for scripts/generate-openclaw-config.ts. +// Functional tests for scripts/generate-openclaw-config.mts. // Runs the actual TypeScript script with controlled env vars and asserts on // the generated openclaw.json output. @@ -12,9 +12,9 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import { buildConfig, main } from "../scripts/generate-openclaw-config.ts"; +import { buildConfig, main } from "../scripts/generate-openclaw-config.mts"; -const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.ts"); +const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.mts"); const SCRIPT_ARGS = ["--experimental-strip-types", SCRIPT_PATH]; /** Minimal env vars required for a valid config generation run. */ @@ -181,7 +181,7 @@ afterEach(() => { // ═══════════════════════════════════════════════════════════════════ // Phase 1: Extraction — behavior-preserving tests // ═══════════════════════════════════════════════════════════════════ -describe("generate-openclaw-config.ts: config generation", () => { +describe("generate-openclaw-config.mts: config generation", () => { it("generates valid JSON with minimal env vars", () => { const config = runConfigScript(); expect(config).toBeDefined(); @@ -1496,7 +1496,7 @@ describe("generate-openclaw-config.ts: config generation", () => { // ═══════════════════════════════════════════════════════════════════ // Phase 2: Auto-disable device auth for non-loopback URLs // ═══════════════════════════════════════════════════════════════════ -describe("generate-openclaw-config.ts: non-loopback auto-disable device auth", () => { +describe("generate-openclaw-config.mts: 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", @@ -1543,7 +1543,7 @@ describe("generate-openclaw-config.ts: non-loopback auto-disable device auth", ( }); }); -describe("generate-openclaw-config.ts: empty-string env vars fall back to defaults", () => { +describe("generate-openclaw-config.mts: 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); @@ -1579,7 +1579,7 @@ describe("generate-openclaw-config.ts: empty-string env vars fall back to defaul }); }); -describe("generate-openclaw-config.ts: numeric env var validation", () => { +describe("generate-openclaw-config.mts: numeric env var validation", () => { function runCapturingStderr(envOverrides: Record): { config: any; stderr: string; diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 77b559cef7..e6c1441f28 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -73,7 +73,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "nemoclaw-start.sh")); writeFixture(path.join("scripts", "codex-acp-wrapper.sh")); writeFixture(path.join("scripts", "lib", "sandbox-init.sh")); - writeFixture(path.join("scripts", "generate-openclaw-config.ts")); + writeFixture(path.join("scripts", "generate-openclaw-config.mts")); writeFixture(path.join("scripts", "seed-wechat-accounts.py")); writeFixture(path.join("scripts", "patch-openclaw-tool-catalog.js")); writeFixture(path.join("scripts", "patch-openclaw-chat-send.js")); @@ -243,7 +243,7 @@ describe("sandbox build context staging", () => { ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "nemoclaw-start.sh"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "codex-acp-wrapper.sh"))).toBe(true); - expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.ts"))).toBe( + expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.mts"))).toBe( true, ); expect(fs.existsSync(path.join(buildCtx, "scripts", "seed-wechat-accounts.py"))).toBe(true); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index ae95cde19a..03a364b187 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -722,7 +722,7 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () path.join(localBin, "nemoclaw-start"), path.join(localBin, "nemoclaw-codex-acp"), path.join(localLib, "sandbox-init.sh"), - path.join(localLib, "generate-openclaw-config.ts"), + path.join(localLib, "generate-openclaw-config.mts"), path.join(localLib, "seed-wechat-accounts.py"), path.join(localLib, "ws-proxy-fix.js"), pluginFile, @@ -750,7 +750,7 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () expect(result.status, result.stderr).toBe(0); const generatorMode = ( - fs.statSync(path.join(localLib, "generate-openclaw-config.ts")).mode & 0o777 + fs.statSync(path.join(localLib, "generate-openclaw-config.mts")).mode & 0o777 ).toString(8); const pluginDirMode = (fs.statSync(pluginDir).mode & 0o777).toString(8); const pluginMode = (fs.statSync(pluginFile).mode & 0o777).toString(8); diff --git a/test/security-c2-dockerfile-injection.test.ts b/test/security-c2-dockerfile-injection.test.ts index 278b7a9098..f8b99362f1 100644 --- a/test/security-c2-dockerfile-injection.test.ts +++ b/test/security-c2-dockerfile-injection.test.ts @@ -112,12 +112,11 @@ describe("C-2 fix: env var pattern (process.env) is safe", () => { } }); - it("semicolons and import statements in URL are literal data", () => { - const dangerous = "http://x; import subprocess; subprocess.run(['id'])"; + it("semicolons and require calls in URL are literal data", () => { + const dangerous = "http://x; require('node:child_process').execSync('id')"; const result = runNode(fixedSource(), { CHAT_UI_URL: dangerous }); - // The URL is treated as data — urlparse may or may not raise, but - // the key property is that no code injection occurs. Check stdout or stderr - // does NOT contain evidence of os.system/subprocess execution. + // The URL is treated as data. The key property is that no injected + // JavaScript executes. const combined = result.stdout + result.stderr; expect(!combined.includes("uid=")).toBeTruthy(); }); @@ -132,6 +131,7 @@ describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth def const lines = src.split("\n"); let promoted = false; let inEnvBlock = false; + let sawGeneratorRun = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (/^\s*FROM\b/.test(line)) { @@ -148,14 +148,15 @@ describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth def inEnvBlock = false; } if ( - /^\s*RUN\b.*node\s+--experimental-strip-types\s+\/usr\/local\/lib\/nemoclaw\/generate-openclaw-config\.ts\b/.test( + /^\s*RUN\b.*node\s+--experimental-strip-types\s+\/usr\/local\/lib\/nemoclaw\/generate-openclaw-config\.mts\b/.test( line, ) ) { + sawGeneratorRun = true; expect(promoted).toBeTruthy(); return; } } - expect(promoted).toBeTruthy(); + expect(sawGeneratorRun).toBeTruthy(); }); }); diff --git a/test/seed-wechat-accounts.test.ts b/test/seed-wechat-accounts.test.ts index c8f9763fdf..126a849263 100644 --- a/test/seed-wechat-accounts.test.ts +++ b/test/seed-wechat-accounts.test.ts @@ -101,7 +101,7 @@ afterEach(() => { describe("seed-wechat-accounts.py: gating", () => { it("no-ops silently when NEMOCLAW_WECHAT_CONFIG_B64 is unset", () => { - // The script now runs unconditionally from generate-openclaw-config.ts + // The script now runs unconditionally from generate-openclaw-config.mts // on every build, so the "no host-side QR login was performed" path is // the common case and must stay quiet — no stderr noise, no on-disk // state under the plugin state dir. @@ -278,7 +278,7 @@ describe("seed-wechat-accounts.py: openclaw.json patching (channels.openclaw-wei it("preserves existing unrelated keys in openclaw.json", () => { // The patch must merge into the existing config — clobbering gateway or - // other channels would break everything else generate-openclaw-config.ts + // other channels would break everything else generate-openclaw-config.mts // wrote moments earlier. writeOpenclawConfig({ gateway: { port: 9999, marker: "keep-me" }, @@ -297,7 +297,7 @@ describe("seed-wechat-accounts.py: openclaw.json patching (channels.openclaw-wei it("restores plugin registration and channel block after a later OpenClaw config rewrite drops them", () => { // The Dockerfile invokes this seed script again after OpenClaw doctor and // plugin installation because those commands can rewrite openclaw.json - // after generate-openclaw-config.ts first runs. Re-running the seed must + // after generate-openclaw-config.mts first runs. Re-running the seed must // be enough to put the upstream WeChat plugin and channel registration // back; otherwise the gateway rejects channels.openclaw-weixin as an // unknown channel id at startup. @@ -398,7 +398,7 @@ describe("seed-wechat-accounts.py: openclaw.json patching (channels.openclaw-wei }); it("bails (and warns) when openclaw.json is missing — does not invent a config", () => { - // generate-openclaw-config.ts runs first and is responsible for producing + // generate-openclaw-config.mts runs first and is responsible for producing // openclaw.json. If it failed silently, we'd rather print a warning than // create a half-formed file from this script's narrow vantage point. const result = runSeed({ From b7bd036700a6b80c84e896bc7f3ff9a07b93c328 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 30 May 2026 15:06:49 -0700 Subject: [PATCH 6/7] Potential fix for pull request finding 'CodeQL / Useless assignment to local variable' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- test/security-c2-dockerfile-injection.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/security-c2-dockerfile-injection.test.ts b/test/security-c2-dockerfile-injection.test.ts index f8b99362f1..b9daa7b7da 100644 --- a/test/security-c2-dockerfile-injection.test.ts +++ b/test/security-c2-dockerfile-injection.test.ts @@ -152,7 +152,6 @@ describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth def line, ) ) { - sawGeneratorRun = true; expect(promoted).toBeTruthy(); return; } From 3acb99254393049ce7aa3685d1d4dc328fc0a900 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 30 May 2026 15:08:13 -0700 Subject: [PATCH 7/7] test(scripts): dedupe generator test env setup Signed-off-by: Carlos Villela --- test/generate-openclaw-config.test.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index fddb0ae53b..365507ea61 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -36,13 +36,17 @@ const BASE_ENV: Record = { let tmpDir: string; -function runConfigScriptRaw(envOverrides: Record = {}) { - const env: Record = { +function buildTestEnv(envOverrides: Record = {}): Record { + return { PATH: process.env.PATH || "/usr/bin:/bin", ...BASE_ENV, ...envOverrides, HOME: tmpDir, }; +} + +function runConfigScriptRaw(envOverrides: Record = {}) { + const env = buildTestEnv(envOverrides); const result = spawnSync("node", SCRIPT_ARGS, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], @@ -54,12 +58,7 @@ function runConfigScriptRaw(envOverrides: Record = {}) { function withConfigEnv(envOverrides: Record, fn: () => T): T { const originalEnv = { ...process.env }; - const env: Record = { - PATH: process.env.PATH || "/usr/bin:/bin", - ...BASE_ENV, - ...envOverrides, - HOME: tmpDir, - }; + const env = buildTestEnv(envOverrides); try { for (const key of Object.keys(process.env)) { delete process.env[key];