Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/e2e-script.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ on:
required: false
BRAVE_API_KEY:
required: false
TELEGRAM_BOT_TOKEN_REAL:
required: false
TELEGRAM_CHAT_ID_E2E:
required: false
DISCORD_BOT_TOKEN_REAL:
required: false
DISCORD_CHANNEL_ID_E2E:
required: false
SLACK_BOT_TOKEN_REAL:
required: false
SLACK_APP_TOKEN_REAL:
required: false
SLACK_CHANNEL_ID_E2E:
required: false

permissions:
contents: read
Expand Down Expand Up @@ -157,3 +171,10 @@ jobs:
BRAVE_API_KEY: ${{ inputs.brave_api_key && secrets.BRAVE_API_KEY || '' }}
GITHUB_TOKEN: ${{ inputs.github_token && github.token || '' }}
NVIDIA_API_KEY: ${{ inputs.nvidia_api_key && secrets.NVIDIA_API_KEY || '' }}
TELEGRAM_BOT_TOKEN_REAL: ${{ secrets.TELEGRAM_BOT_TOKEN_REAL }}
TELEGRAM_CHAT_ID_E2E: ${{ secrets.TELEGRAM_CHAT_ID_E2E }}
DISCORD_BOT_TOKEN_REAL: ${{ secrets.DISCORD_BOT_TOKEN_REAL }}
DISCORD_CHANNEL_ID_E2E: ${{ secrets.DISCORD_CHANNEL_ID_E2E }}
SLACK_BOT_TOKEN_REAL: ${{ secrets.SLACK_BOT_TOKEN_REAL }}
SLACK_APP_TOKEN_REAL: ${{ secrets.SLACK_APP_TOKEN_REAL }}
SLACK_CHANNEL_ID_E2E: ${{ secrets.SLACK_CHANNEL_ID_E2E }}
7 changes: 7 additions & 0 deletions .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,13 @@ jobs:
secrets:
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }}
TELEGRAM_BOT_TOKEN_REAL: ${{ secrets.TELEGRAM_BOT_TOKEN_REAL }}
TELEGRAM_CHAT_ID_E2E: ${{ secrets.TELEGRAM_CHAT_ID_E2E }}
DISCORD_BOT_TOKEN_REAL: ${{ secrets.DISCORD_BOT_TOKEN_REAL }}
DISCORD_CHANNEL_ID_E2E: ${{ secrets.DISCORD_CHANNEL_ID_E2E }}
SLACK_BOT_TOKEN_REAL: ${{ secrets.SLACK_BOT_TOKEN_REAL }}
SLACK_APP_TOKEN_REAL: ${{ secrets.SLACK_APP_TOKEN_REAL }}
SLACK_CHANNEL_ID_E2E: ${{ secrets.SLACK_CHANNEL_ID_E2E }}
openclaw-slack-pairing-e2e:
if: >-
github.repository == 'NVIDIA/NemoClaw' && (github.event_name != 'workflow_dispatch' ||
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -395,11 +395,13 @@ COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start
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/openclaw-build-messaging-plugins.py /usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.py
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/openclaw-build-messaging-plugins.py \
/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 \
Expand Down Expand Up @@ -543,7 +545,7 @@ USER sandbox
RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 python3 /usr/local/lib/nemoclaw/generate-openclaw-config.py

# hadolint ignore=DL3059,DL4006
RUN openclaw doctor --fix --non-interactive
RUN python3 /usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.py

# Lock down npm: no further registry traffic in this image. Everything past
# this point must resolve from local sources only.
Expand Down
13 changes: 6 additions & 7 deletions scripts/generate-openclaw-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,11 +566,9 @@ def _placeholder(channel: str, env_key: str) -> str:
for ch in msg_channels:
if ch == "whatsapp":
_ch_cfg[ch] = {
"enabled": True,
"accounts": {
"default": {
"enabled": True,
"healthMonitor": {"enabled": False},
}
"default": {"enabled": True, "healthMonitor": {"enabled": False}}
}
}
continue
Expand All @@ -585,7 +583,6 @@ def _placeholder(channel: str, env_key: str) -> str:
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"
Expand All @@ -611,7 +608,7 @@ def _placeholder(channel: str, env_key: str) -> str:
channel_id: dict(slack_channel_config)
for channel_id in _slack_allowed_channels
}
_ch_cfg[ch] = {**({"enabled": True} if ch == "slack" else {}), "accounts": {"default": account}}
_ch_cfg[ch] = {"enabled": True, "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
Expand Down Expand Up @@ -716,7 +713,9 @@ def _placeholder(channel: str, env_key: str) -> str:
# registered an accountId under channels.openclaw-weixin.accounts.
"openclaw-weixin": {"enabled": True},
}
plugin_entries.update({"slack": {"enabled": True}} if "slack" in _ch_cfg else {})
plugin_entries.update(
{ch: {"enabled": True} for ch in ("discord", "slack", "telegram", "whatsapp") if ch in _ch_cfg}
)
_bundled_provider_plugins = {
"amazon-bedrock": {"amazon-bedrock", "bedrock"},
"amazon-bedrock-mantle": {"amazon-bedrock-mantle"},
Expand Down
153 changes: 153 additions & 0 deletions scripts/openclaw-build-messaging-plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Install OpenClaw messaging plugins that match the bundled OpenClaw version.

OpenClaw's doctor repair uses the official catalog's unversioned plugin specs.
That can drift to a newer external messaging plugin than the host OpenClaw
runtime. NemoClaw pins the runtime with OPENCLAW_VERSION, so build-time channel
activation must pin external messaging plugins to that same version.
"""

from __future__ import annotations

import argparse
import base64
import json
import os
import subprocess
import sys
from typing import Iterable


DEFAULT_CHANNELS_B64 = "W10="

EXTERNAL_CHANNEL_PACKAGES = {
"discord": "@openclaw/discord",
"slack": "@openclaw/slack",
"whatsapp": "@openclaw/whatsapp",
}

DOCTOR_ENV_BY_CHANNEL = {
"telegram": {
"TELEGRAM_BOT_TOKEN": "openshell:resolve:env:TELEGRAM_BOT_TOKEN",
},
"discord": {
"DISCORD_BOT_TOKEN": "openshell:resolve:env:DISCORD_BOT_TOKEN",
},
"slack": {
"SLACK_BOT_TOKEN": "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",
"SLACK_APP_TOKEN": "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN",
},
}


class BuildMessagingPluginError(RuntimeError):
"""Raised for configuration errors that should fail the image build."""


def decode_channels(raw: str) -> list[str]:
try:
decoded = base64.b64decode(raw, validate=True)
parsed = json.loads(decoded.decode("utf-8"))
except Exception as exc: # noqa: BLE001 - keep the build error actionable.
raise BuildMessagingPluginError(
"NEMOCLAW_MESSAGING_CHANNELS_B64 must be base64-encoded JSON array"
) from exc

if not isinstance(parsed, list):
raise BuildMessagingPluginError(
"NEMOCLAW_MESSAGING_CHANNELS_B64 must decode to a JSON array"
)

channels: list[str] = []
seen: set[str] = set()
for item in parsed:
if not isinstance(item, str):
raise BuildMessagingPluginError(
"NEMOCLAW_MESSAGING_CHANNELS_B64 may contain only string channel names"
)
channel = item.strip().lower()
if not channel or channel in seen:
continue
seen.add(channel)
channels.append(channel)
return channels


def require_openclaw_version(channels: Iterable[str], env: dict[str, str]) -> str:
needs_external_install = any(channel in EXTERNAL_CHANNEL_PACKAGES for channel in channels)
version = (env.get("OPENCLAW_VERSION") or "").strip()
if needs_external_install and not version:
raise BuildMessagingPluginError(
"OPENCLAW_VERSION is required when external messaging channels are enabled"
)
return version


def plugin_specs(channels: Iterable[str], openclaw_version: str) -> list[str]:
specs: list[str] = []
for channel in channels:
package_name = EXTERNAL_CHANNEL_PACKAGES.get(channel)
if package_name:
specs.append(f"{package_name}@{openclaw_version}")
return specs


def doctor_env_overrides(channels: Iterable[str]) -> dict[str, str]:
overrides: dict[str, str] = {}
for channel in channels:
overrides.update(DOCTOR_ENV_BY_CHANNEL.get(channel, {}))
return overrides


def run_command(args: list[str], *, env: dict[str, str] | None = None) -> None:
print("+ " + " ".join(args), flush=True)
subprocess.run(args, check=True, env=env)


def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the derived plugin specs and doctor env overrides as JSON.",
)
args = parser.parse_args(argv)

raw_channels = os.environ.get("NEMOCLAW_MESSAGING_CHANNELS_B64", DEFAULT_CHANNELS_B64)
channels = decode_channels(raw_channels or DEFAULT_CHANNELS_B64)
openclaw_version = require_openclaw_version(channels, os.environ)
specs = plugin_specs(channels, openclaw_version)
env_overrides = doctor_env_overrides(channels)

if args.dry_run:
print(
json.dumps(
{
"channels": channels,
"doctorEnv": env_overrides,
"installSpecs": specs,
"openclawVersion": openclaw_version,
},
indent=2,
sort_keys=True,
)
)
return 0

for spec in specs:
run_command(["openclaw", "plugins", "install", spec])

doctor_env = os.environ.copy()
doctor_env.update(env_overrides)
run_command(["openclaw", "doctor", "--fix", "--non-interactive"], env=doctor_env)
return 0


if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv[1:]))
except BuildMessagingPluginError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)
4 changes: 4 additions & 0 deletions src/lib/sandbox/build-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ function stageOptimizedSandboxBuildContext(
path.join(rootDir, "scripts", "generate-openclaw-config.py"),
path.join(stagedScriptsDir, "generate-openclaw-config.py"),
);
fs.copyFileSync(
path.join(rootDir, "scripts", "openclaw-build-messaging-plugins.py"),
path.join(stagedScriptsDir, "openclaw-build-messaging-plugins.py"),
);
// WeChat-account seed for the @tencent-weixin/openclaw-weixin plugin —
// runs at image build time when WeChat is enabled to skip the upstream
// plugin's in-sandbox QR login.
Expand Down
22 changes: 18 additions & 4 deletions test/e2e-script-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,27 @@ describe("E2E reusable workflow contract", () => {

it("passes only named secrets to reusable nightly jobs", () => {
const reusableJobs = reusableNightlyJobs(nightlyWorkflow);
const defaultSecrets = {
NVIDIA_API_KEY: "${{ secrets.NVIDIA_API_KEY }}",
BRAVE_API_KEY: "${{ secrets.BRAVE_API_KEY }}",
};
const messagingLiveSecrets = {
TELEGRAM_BOT_TOKEN_REAL: "${{ secrets.TELEGRAM_BOT_TOKEN_REAL }}",
TELEGRAM_CHAT_ID_E2E: "${{ secrets.TELEGRAM_CHAT_ID_E2E }}",
DISCORD_BOT_TOKEN_REAL: "${{ secrets.DISCORD_BOT_TOKEN_REAL }}",
DISCORD_CHANNEL_ID_E2E: "${{ secrets.DISCORD_CHANNEL_ID_E2E }}",
SLACK_BOT_TOKEN_REAL: "${{ secrets.SLACK_BOT_TOKEN_REAL }}",
SLACK_APP_TOKEN_REAL: "${{ secrets.SLACK_APP_TOKEN_REAL }}",
SLACK_CHANNEL_ID_E2E: "${{ secrets.SLACK_CHANNEL_ID_E2E }}",
};

expect(reusableJobs.length).toBeGreaterThan(20);
for (const [name, job] of reusableJobs) {
expect(job.secrets, name).toEqual({
NVIDIA_API_KEY: "${{ secrets.NVIDIA_API_KEY }}",
BRAVE_API_KEY: "${{ secrets.BRAVE_API_KEY }}",
});
const expectedSecrets =
name === "messaging-providers-e2e"
? { ...defaultSecrets, ...messagingLiveSecrets }
: defaultSecrets;
expect(job.secrets, name).toEqual(expectedSecrets);
}
});

Expand Down
Loading
Loading