Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 1 addition & 12 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -381,19 +381,8 @@ USER sandbox
# list of env vars and derivation rules.
RUN python3 /usr/local/lib/nemoclaw/generate-openclaw-config.py

# TEMPORARY: install the WeChat plugin here (was moved to Dockerfile.base in
# e23486b but the wholesale rewrite by generate-openclaw-config.py above
# blew away plugins.installs.openclaw-weixin from base's openclaw.json,
# leaving the plugin unloadable at runtime and taking Telegram down with it).
# Running the install AFTER generate-openclaw-config.py merges the registry
# entry into the freshly-written config. Seed the per-account state right
# after so the bridge picks up the captured iLink session.
# hadolint ignore=DL3059,DL4006
RUN (openclaw doctor --fix > /dev/null 2>&1 || true) \
&& openclaw plugins install \
'@tencent-weixin/openclaw-weixin@2.4.2' --pin \
&& openclaw config set plugins.entries.openclaw-weixin.enabled true \
&& python3 /usr/local/lib/nemoclaw/seed-wechat-accounts.py
RUN openclaw doctor --fix --non-interactive

# Lock down npm: no further registry traffic in this image. Everything past
# this point must resolve from local sources only.
Expand Down
9 changes: 9 additions & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,12 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep
fi; \
npm install -g "openclaw@${OPENCLAW_VERSION}" \
&& pip3 install --no-cache-dir --break-system-packages "pyyaml==6.0.3"

USER sandbox
WORKDIR /sandbox
# hadolint ignore=DL3059,DL4006
RUN openclaw plugins install '@tencent-weixin/openclaw-weixin@2.4.2' --pin \
&& openclaw config set plugins.entries.openclaw-weixin.enabled true
# hadolint ignore=DL3002
USER root
WORKDIR /
52 changes: 48 additions & 4 deletions scripts/generate-openclaw-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import json
import os
import re
import runpy
import sys
from pathlib import Path
from urllib.parse import urlparse
Expand Down Expand Up @@ -700,18 +701,61 @@ def _placeholder(channel: str, env_key: str) -> str:
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 _seed_wechat_accounts_if_installed(config: dict) -> None:
if not _has_plugin_install(config, "openclaw-weixin"):
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)
# NOTE: seed-wechat-accounts.py is invoked separately from the Dockerfile
# AFTER `openclaw plugins install`. Calling it here would write
# channels.openclaw-weixin before the plugin registers its channel id,
# which makes the install fail with "unknown channel id: openclaw-weixin".
_seed_wechat_accounts_if_installed(config)


if __name__ == "__main__":
Expand Down
7 changes: 2 additions & 5 deletions scripts/seed-wechat-accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,8 @@
# is registered. Without channels.openclaw-weixin.accounts.<id>.enabled=true
# in openclaw.json, the plugin's auth/accounts.ts considers the account
# disabled and the bridge won't start, even if the per-account state files
# above exist. We mutate openclaw.json HERE (post-install) rather than in
# generate-openclaw-config.py because writing channels.openclaw-weixin
# upfront races with `openclaw plugins install`, which fails with "unknown
# channel id: openclaw-weixin" if the channel block exists before the plugin
# has registered it.
# above exist. generate-openclaw-config.py invokes this only after
# plugins.installs.openclaw-weixin has been preserved from the base image.
#
# State dir resolution mirrors the upstream's resolveStateDir():
# $OPENCLAW_STATE_DIR || $CLAWDBOT_STATE_DIR || ~/.openclaw
Expand Down
70 changes: 63 additions & 7 deletions test/generate-openclaw-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,7 @@ describe("generate-openclaw-config.py: config generation", () => {
expect(config.channels.telegram.groups).toBeUndefined();
});

it("does not write channels.openclaw-weixin from generate-openclaw-config (Dockerfile seed runs separately)", () => {
// Commit a21e123 reverted the chained seed: generate-openclaw-config.py
// intentionally leaves channels.openclaw-weixin unset, even when a
// wechatConfig is provided. The Dockerfile invokes
// seed-wechat-accounts.py separately, AFTER `openclaw plugins install`
// registers the openclaw-weixin channel id. Writing the channel block
// here would trigger "unknown channel id: openclaw-weixin" on install.
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(
JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }),
Expand All @@ -247,6 +241,49 @@ describe("generate-openclaw-config.py: config generation", () => {
expect(config.channels?.wechat).toBeUndefined();
});

it("seeds channels.openclaw-weixin when the base plugin install registry exists", () => {
const configPath = path.join(tmpDir, ".openclaw", "openclaw.json");
const installEntry = {
type: "npm",
spec: "@tencent-weixin/openclaw-weixin@2.4.2",
resolved: "@tencent-weixin/openclaw-weixin@2.4.2",
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(
configPath,
JSON.stringify({ plugins: { installs: { "openclaw-weixin": installEntry } } }),
);

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(installEntry);
expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({
enabled: true,
});
expect(config.channels?.wechat).toBeUndefined();

const accountFile = path.join(
tmpDir,
".openclaw",
"openclaw-weixin",
"accounts",
"primary.json",
);
const account = JSON.parse(fs.readFileSync(accountFile, "utf-8"));
expect(account).toMatchObject({
token: "openshell:resolve:env:WECHAT_BOT_TOKEN",
baseUrl: "https://example",
userId: "u1",
});
});

it("omits channels.openclaw-weixin when no accountId was captured", () => {
// No QR-login result → seed step bails on the empty accountId and
// leaves openclaw.json untouched, so the bridge stays dormant.
Expand All @@ -264,6 +301,25 @@ describe("generate-openclaw-config.py: config generation", () => {
expect(config.plugins?.entries?.["openclaw-weixin"]?.enabled).toBe(true);
});

it("preserves base-image plugin install registry entries", () => {
const configPath = path.join(tmpDir, ".openclaw", "openclaw.json");
fs.mkdirSync(path.dirname(configPath), { recursive: true });
const installEntry = {
type: "npm",
spec: "@tencent-weixin/openclaw-weixin@2.4.2",
resolved: "@tencent-weixin/openclaw-weixin@2.4.2",
};
fs.writeFileSync(
configPath,
JSON.stringify({ plugins: { installs: { "openclaw-weixin": installEntry } } }),
);

const config = runConfigScript({});

expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual(installEntry);
expect(config.plugins?.entries?.["openclaw-weixin"]?.enabled).toBe(true);
});

it("emits canonical openshell:resolve:env: placeholders for non-Slack channels", () => {
const channels = Buffer.from(JSON.stringify(["telegram", "discord"])).toString("base64");
const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels });
Expand Down
Loading