diff --git a/.github/actions/resolve-sandbox-base-image/action.yaml b/.github/actions/resolve-sandbox-base-image/action.yaml index 52e2f7a9886..e2c163aeb70 100644 --- a/.github/actions/resolve-sandbox-base-image/action.yaml +++ b/.github/actions/resolve-sandbox-base-image/action.yaml @@ -14,6 +14,7 @@ runs: image="ghcr.io/nvidia/nemoclaw/sandbox-base" min_glibc="2.39" + base_inputs=(Dockerfile.base nemoclaw-blueprint/blueprint.yaml) glibc_version() { docker run --rm --entrypoint /usr/bin/ldd "$1" --version 2>/dev/null \ @@ -40,11 +41,37 @@ runs: return 0 } + base_inputs_changed() { + if ! git diff --quiet -- "${base_inputs[@]}"; then + return 0 + fi + + local base_ref="${GITHUB_BASE_REF:-main}" + git fetch --no-tags --depth=1 origin \ + "+refs/heads/${base_ref}:refs/remotes/origin/${base_ref}" >/dev/null 2>&1 || true + if ! git rev-parse --verify "origin/${base_ref}^{commit}" >/dev/null 2>&1; then + return 1 + fi + + ! git diff --quiet "origin/${base_ref}" HEAD -- "${base_inputs[@]}" + } + + use_local_base() { + local version + echo "::notice::Sandbox base image inputs changed in this checkout; building Dockerfile.base locally" + docker build -f Dockerfile.base -t nemoclaw-sandbox-base-local . + version="$(glibc_version nemoclaw-sandbox-base-local || true)" + if ! glibc_ok "$version"; then + echo "::error::Local sandbox base image has glibc ${version:-unknown}; need >= ${min_glibc}" + exit 1 + fi + echo "BASE_IMAGE=nemoclaw-sandbox-base-local" >> "$GITHUB_ENV" + } + candidates=() if [[ -n "${GITHUB_SHA:-}" ]]; then candidates+=("${image}:${GITHUB_SHA:0:8}" "${image}:${GITHUB_SHA:0:7}") fi - candidates+=("${image}:latest") for ref in "${candidates[@]}"; do if try_image "$ref"; then @@ -52,11 +79,14 @@ runs: fi done - echo "::warning::No compatible GHCR sandbox base image found, building locally" - docker build -f Dockerfile.base -t nemoclaw-sandbox-base-local . - version="$(glibc_version nemoclaw-sandbox-base-local || true)" - if ! glibc_ok "$version"; then - echo "::error::Local sandbox base image has glibc ${version:-unknown}; need >= ${min_glibc}" - exit 1 + if base_inputs_changed; then + use_local_base + exit 0 fi - echo "BASE_IMAGE=nemoclaw-sandbox-base-local" >> "$GITHUB_ENV" + + if try_image "${image}:latest"; then + exit 0 + fi + + echo "::warning::No compatible GHCR sandbox base image found, building locally" + use_local_base diff --git a/Dockerfile b/Dockerfile index 33b9d267d43..c08a98241ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -382,19 +382,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. diff --git a/Dockerfile.base b/Dockerfile.base index f49a0d4e7c9..4d920cb614b 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -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 / diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index b2e2117b099..0e3e3456d6c 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -44,6 +44,7 @@ import json import os import re +import runpy import sys from pathlib import Path from urllib.parse import urlparse @@ -738,18 +739,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__": diff --git a/scripts/seed-wechat-accounts.py b/scripts/seed-wechat-accounts.py index 55f22c9aad2..eff6c49742c 100755 --- a/scripts/seed-wechat-accounts.py +++ b/scripts/seed-wechat-accounts.py @@ -19,11 +19,8 @@ # is registered. Without channels.openclaw-weixin.accounts..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 diff --git a/src/lib/sandbox-base-image.test.ts b/src/lib/sandbox-base-image.test.ts index 6c22cd20ee6..c0c5585d2e8 100644 --- a/src/lib/sandbox-base-image.test.ts +++ b/src/lib/sandbox-base-image.test.ts @@ -1,15 +1,82 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { + baseImageInputsChangedSinceMain, formatBuildFailureDiagnostics, getSourceShortShaTags, parseGlibcVersion, versionGte, } from "../../dist/lib/sandbox-base-image"; +const tmpRoots: string[] = []; +const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "Test User", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test User", + GIT_COMMITTER_EMAIL: "test@example.com", +}; + +function git(root: string, args: string[]) { + const result = spawnSync("git", ["-C", root, ...args], { + encoding: "utf-8", + env: gitEnv, + }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed:\n${result.stderr}\n${result.stdout}`); + } + return result.stdout.trim(); +} + +function writeFixture(root: string, relativePath: string, contents: string) { + const absolutePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, contents); +} + +function createGitFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-image-test-")); + tmpRoots.push(root); + git(root, ["init", "-b", "main"]); + writeFixture(root, "Dockerfile.base", "FROM node:22\n"); + writeFixture(root, "nemoclaw-blueprint/blueprint.yaml", "min_openclaw_version: 2026.4.24\n"); + writeFixture(root, "src/other.ts", "export const value = 1;\n"); + git(root, ["add", "."]); + git(root, ["commit", "-m", "initial"]); + git(root, ["update-ref", "refs/remotes/origin/main", "HEAD"]); + return root; +} + +function createGitFixtureWithRemoteOnlyBaseRef() { + const remote = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-image-remote-")); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-image-clone-")); + tmpRoots.push(root, remote); + + git(remote, ["init", "--bare"]); + git(root, ["init", "-b", "main"]); + writeFixture(root, "Dockerfile.base", "FROM node:22\n"); + writeFixture(root, "nemoclaw-blueprint/blueprint.yaml", "min_openclaw_version: 2026.4.24\n"); + writeFixture(root, "src/other.ts", "export const value = 1;\n"); + git(root, ["add", "."]); + git(root, ["commit", "-m", "initial"]); + git(root, ["remote", "add", "origin", remote]); + git(root, ["push", "origin", "main"]); + return root; +} + +afterEach(() => { + for (const root of tmpRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + describe("sandbox base image helpers", () => { it("parses glibc versions from ldd output", () => { expect(parseGlibcVersion("ldd (Debian GLIBC 2.41-12+deb13u2) 2.41")).toBe("2.41"); @@ -75,4 +142,53 @@ describe("sandbox base image helpers", () => { }); expect(output).toContain("buffered build error"); }); + + it("detects committed Dockerfile.base changes relative to origin/main", () => { + const root = createGitFixture(); + git(root, ["switch", "-c", "feature"]); + writeFixture(root, "Dockerfile.base", "FROM node:22\nRUN echo changed\n"); + git(root, ["add", "Dockerfile.base"]); + git(root, ["commit", "-m", "change base"]); + + expect(baseImageInputsChangedSinceMain(root, gitEnv)).toBe(true); + }); + + it("fetches the base ref before deciding detached dispatch checkouts can use latest", () => { + const root = createGitFixtureWithRemoteOnlyBaseRef(); + git(root, ["switch", "-c", "feature"]); + writeFixture(root, "Dockerfile.base", "FROM node:22\nRUN echo changed\n"); + git(root, ["add", "Dockerfile.base"]); + git(root, ["commit", "-m", "change base"]); + + expect(git(root, ["rev-parse", "--verify", "origin/main"]).length).toBeGreaterThan(0); + git(root, ["update-ref", "-d", "refs/remotes/origin/main"]); + expect(baseImageInputsChangedSinceMain(root, { ...gitEnv, GITHUB_ACTIONS: "true" })).toBe(true); + }); + + it("detects committed blueprint minimum-version changes relative to origin/main", () => { + const root = createGitFixture(); + git(root, ["switch", "-c", "feature"]); + writeFixture(root, "nemoclaw-blueprint/blueprint.yaml", "min_openclaw_version: 2026.4.25\n"); + git(root, ["add", "nemoclaw-blueprint/blueprint.yaml"]); + git(root, ["commit", "-m", "change base input"]); + + expect(baseImageInputsChangedSinceMain(root, gitEnv)).toBe(true); + }); + + it("ignores non-base-image source changes relative to origin/main", () => { + const root = createGitFixture(); + git(root, ["switch", "-c", "feature"]); + writeFixture(root, "src/other.ts", "export const value = 2;\n"); + git(root, ["add", "src/other.ts"]); + git(root, ["commit", "-m", "change app code"]); + + expect(baseImageInputsChangedSinceMain(root, gitEnv)).toBe(false); + }); + + it("detects uncommitted Dockerfile.base changes", () => { + const root = createGitFixture(); + writeFixture(root, "Dockerfile.base", "FROM node:22\nRUN echo dirty\n"); + + expect(baseImageInputsChangedSinceMain(root, gitEnv)).toBe(true); + }); }); diff --git a/src/lib/sandbox-base-image.ts b/src/lib/sandbox-base-image.ts index 87f5c0abfc5..2f8fa5251e1 100644 --- a/src/lib/sandbox-base-image.ts +++ b/src/lib/sandbox-base-image.ts @@ -36,6 +36,8 @@ export type SandboxBaseImageResolution = { glibcVersion: string | null; }; +const BASE_IMAGE_INPUT_PATHS = ["Dockerfile.base", "nemoclaw-blueprint/blueprint.yaml"]; + /** * Combine stderr + stdout from a captured `dockerBuild` failure and pass them * through the runner's redaction so secrets in build output never reach the @@ -115,6 +117,93 @@ export function getSourceShortShaTags(rootDir = ROOT, env: NodeJS.ProcessEnv = p return Array.from(new Set(values)); } +function gitStatus(rootDir: string, args: string[], env: NodeJS.ProcessEnv = process.env): number | null { + const git = spawnSync("git", ["-C", rootDir, ...args], { + encoding: "utf-8", + stdio: "ignore", + timeout: 5_000, + env, + }); + return git.status; +} + +function gitRefExists(rootDir: string, ref: string, env: NodeJS.ProcessEnv = process.env): boolean { + return gitStatus(rootDir, ["rev-parse", "--verify", `${ref}^{commit}`], env) === 0; +} + +function gitFetchRemoteBranch( + rootDir: string, + remote: string, + branch: string, + localRef: string, + env: NodeJS.ProcessEnv = process.env, +): void { + const normalizedBranch = String(branch || "").trim(); + if (!normalizedBranch) return; + + spawnSync( + "git", + [ + "-C", + rootDir, + "fetch", + "--no-tags", + "--depth=1", + remote, + `+refs/heads/${normalizedBranch}:${localRef}`, + ], + { + encoding: "utf-8", + stdio: "ignore", + timeout: 30_000, + env: { ...env, GIT_TERMINAL_PROMPT: "0" }, + }, + ); +} + +function gitHasPathDiff( + rootDir: string, + args: string[], + env: NodeJS.ProcessEnv = process.env, +): boolean | null { + const status = gitStatus(rootDir, [...args, "--", ...BASE_IMAGE_INPUT_PATHS], env); + if (status === 0) return false; + if (status === 1) return true; + return null; +} + +export function baseImageInputsChangedSinceMain( + rootDir = ROOT, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const worktreeDiff = gitHasPathDiff(rootDir, ["diff", "--quiet"], env); + if (worktreeDiff === true) return true; + + const stagedDiff = gitHasPathDiff(rootDir, ["diff", "--cached", "--quiet"], env); + if (stagedDiff === true) return true; + + const baseBranch = String(env.GITHUB_BASE_REF || "main").trim() || "main"; + const baseRemoteRef = `origin/${baseBranch}`; + if (!gitRefExists(rootDir, baseRemoteRef, env)) { + gitFetchRemoteBranch(rootDir, "origin", baseBranch, `refs/remotes/origin/${baseBranch}`, env); + } + + const candidates = [ + baseRemoteRef, + "origin/main", + "upstream/main", + "main", + ].filter((ref): ref is string => !!ref); + + for (const ref of Array.from(new Set(candidates))) { + if (!gitRefExists(rootDir, ref, env)) continue; + const diff = gitHasPathDiff(rootDir, ["diff", "--quiet", ref, "HEAD"], env); + if (diff != null) return diff; + } + + return false; +} + function localBuildAllowed(env: NodeJS.ProcessEnv = process.env): boolean { const raw = String(env.NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD || "auto") .trim() @@ -265,6 +354,18 @@ export function resolveSandboxBaseImage( if (resolved) return resolved; } + if (baseImageInputsChangedSinceMain(options.rootDir || ROOT, env)) { + const local = resolveLocalCandidate(options); + if (local) return local; + // The base Dockerfile changed, so fail closed instead of silently using stale :latest. + return { + ref: options.localTag, + digest: null, + source: "local", + glibcVersion: null, + }; + } + const latestRef = `${options.imageName}:${SANDBOX_BASE_TAG}`; const resolved = resolvePulledCandidate(options.imageName, latestRef, "latest", options); if (resolved) return resolved; diff --git a/test/e2e/docs/parity-map.yaml b/test/e2e/docs/parity-map.yaml index 4da0e2435a4..889c91e37bb 100644 --- a/test/e2e/docs/parity-map.yaml +++ b/test/e2e/docs/parity-map.yaml @@ -4778,6 +4778,136 @@ scripts: reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking owner: e2e-maintainers runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA0: channels add whatsapp registered QR-only channel' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA0: channels add whatsapp failed or did not register channel' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA1: Unexpected WhatsApp bridge provider exists in gateway' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA1: WhatsApp QR-only channel creates no bridge provider' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA2: registry.messagingChannels contains whatsapp after channel add' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA2: registry.messagingChannels missing whatsapp after channel add ($(registry_field messagingChannels))' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA3: WhatsApp policy preset applied before rebuild' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA3: WhatsApp policy preset missing expected endpoints before rebuild' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA4: Rebuild completed after WhatsApp channel add' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA4: Rebuild failed after WhatsApp channel add' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA5: WhatsApp policy preset survived rebuild with Node binary scope' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA5: WhatsApp policy preset missing expected endpoints/binaries after rebuild' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA6: Sandbox ''$SANDBOX_NAME'' is Ready after WhatsApp rebuild' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA6: Sandbox ''$SANDBOX_NAME'' not Ready after WhatsApp rebuild (list: ${sandbox_list:0:200})' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA7a: WhatsApp credential-like env var found in sandbox environment' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA7a: No WhatsApp credential-like env var present in sandbox environment' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA7b: WhatsApp credential placeholder found in sandbox process list' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA7b: No WhatsApp credential placeholder present in sandbox process list' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA7c: WhatsApp host credential material found on sandbox filesystem: ${sandbox_fs_wa}' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA7c: No WhatsApp host credential material found on sandbox filesystem' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA8: WhatsApp account is enabled in openclaw.json' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA8: WhatsApp account missing or disabled in openclaw.json (${whatsapp_account_json:0:200})' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA8a: WhatsApp health monitor is disabled for unpaired QR session' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA8a: WhatsApp health monitor is not disabled (${whatsapp_account_json:0:200})' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA9: WhatsApp config has no token/auth/session provider placeholders' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs + - legacy: 'M-WA9: WhatsApp config contains secret-like fields: ${whatsapp_secret_fields}' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + runner_requirement: sandbox runner with NemoClaw/OpenShell CLIs - legacy: 'M1: Provider ''${SANDBOX_NAME}-telegram-bridge'' exists in gateway' status: deferred reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking diff --git a/test/e2e/test-rebuild-openclaw.sh b/test/e2e/test-rebuild-openclaw.sh index 7ce79484d16..726334f4f96 100755 --- a/test/e2e/test-rebuild-openclaw.sh +++ b/test/e2e/test-rebuild-openclaw.sh @@ -375,7 +375,12 @@ fi # No credentials in backup BACKUP_DIR="$HOME/.nemoclaw/rebuild-backups/${SANDBOX_NAME}" if [ -d "$BACKUP_DIR" ]; then - CRED_LEAKS=$(find "$BACKUP_DIR" \( -name "*.json" -o -name "*.env" -o -name ".env" \) -exec grep -l "nvapi-\|sk-\|Bearer " {} \; 2>/dev/null || true) + # Dependency lockfiles can contain public package metadata matching coarse + # token patterns; the product snapshot filter excludes them too. + CRED_LEAKS=$(find "$BACKUP_DIR" \ + \( -name "package-lock.json" -o -name "npm-shrinkwrap.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" -o -name "pnpm-lock.yml" \) -prune -o \ + \( -name "*.json" -o -name "*.env" -o -name ".env" \) -type f \ + -exec grep -l "nvapi-\|sk-\|Bearer " {} \; 2>/dev/null || true) if [ -z "$CRED_LEAKS" ]; then pass "No credentials in backup" else diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 20bd88e9488..27633c4d55b 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -248,13 +248,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" }), @@ -269,6 +263,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. @@ -286,6 +323,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 });