Skip to content
Merged
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
46 changes: 38 additions & 8 deletions .github/actions/resolve-sandbox-base-image/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -40,23 +41,52 @@ 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
exit 0
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
13 changes: 1 addition & 12 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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 @@ -44,6 +44,7 @@
import json
import os
import re
import runpy
import sys
from pathlib import Path
from urllib.parse import urlparse
Expand Down Expand Up @@ -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__":
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
118 changes: 117 additions & 1 deletion src/lib/sandbox-base-image.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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);
});
});
Loading
Loading