diff --git a/Dockerfile b/Dockerfile index cba29fb48d0..a6a59702494 100644 --- a/Dockerfile +++ b/Dockerfile @@ -388,6 +388,17 @@ USER sandbox # the OpenShell proxy. Mirror of the Telegram treatment immediately below. # Remove once OpenClaw lands an env-var-honouring fix for the Discord # gateway equivalent to openclaw/openclaw#62878 (Slack Socket Mode). +# Ensure the WeChat OpenClaw extension exists in the final image, even when +# the published sandbox-base:latest tag predates the Dockerfile.base install +# layer. The build-time config generator may seed channels.openclaw-weixin, so +# the gateway must always have the matching plugin payload available. +# hadolint ignore=DL3059,DL4006 +RUN if [ ! -d /sandbox/.openclaw/extensions/openclaw-weixin ]; then \ + openclaw plugins install '@tencent-weixin/openclaw-weixin@2.4.2' --pin; \ + fi \ + && test -d /sandbox/.openclaw/extensions/openclaw-weixin \ + && openclaw config set plugins.entries.openclaw-weixin.enabled true + # Generate openclaw.json from environment variables. Config generation logic # lives in scripts/generate-openclaw-config.py — see that file for the full # list of env vars and derivation rules. diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 5fca56ce188..2615c66fe9d 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -807,6 +807,32 @@ export async function rebuildSandbox( ` ${D}Post-upgrade structure check skipped (doctor returned ${doctorResult?.status ?? "null"})${R}`, ); } + + // doctor --fix may rewrite openclaw.json after the image build seeded the + // WeChat account/channel block. Re-run the image-bundled seed helper when + // present so channels.openclaw-weixin remains paired with the preserved + // openclaw-weixin extension after rebuild restore. + log("Reapplying WeChat account seed after post-upgrade structure repair"); + const seedWechatCommand = [ + "if [ -f /usr/local/lib/nemoclaw/seed-wechat-accounts.py ]; then", + "python3 /usr/local/lib/nemoclaw/seed-wechat-accounts.py;", + "else", + "echo '[nemoclaw] seed-wechat-accounts.py not present; skipping';", + "fi", + ].join(" "); + const seedWechatResult = executeSandboxCommand(sandboxName, seedWechatCommand); + log( + `seed-wechat-accounts.py: exit=${seedWechatResult?.status}, stdout=${(seedWechatResult?.stdout || "").substring(0, 200)}`, + ); + if (seedWechatResult && seedWechatResult.status === 0) { + if (!seedWechatResult.stdout.includes("not present; skipping")) { + console.log(` ${G}\u2713${R} WeChat account seed reapplied`); + } + } else { + console.log( + ` ${D}WeChat account seed skipped (seed helper returned ${seedWechatResult?.status ?? "null"})${R}`, + ); + } } // Hermes: no explicit post-restore step needed. Hermes's SessionDB._init_schema() // auto-migrates state.db (SQLite) on first connection via sequential ALTER TABLE diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 042ddb7ffee..8eb996aafe9 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -582,6 +582,7 @@ const AUDIT_SYMLINK_WHITELIST: ReadonlyMap = new Map([ ]); const EXTENSION_NPM_BIN_RE = /^extensions\/[^/]+\/node_modules\/\.bin\/[^/]+$/; +const OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS = ["nemoclaw", "openclaw-weixin"] as const; function isAllowedExtensionNpmBinSymlink(relPath: string, linkTarget: string): boolean { const normalizedRelPath = relPath.split(path.sep).join("/"); @@ -668,6 +669,71 @@ function existingBackupDirs(backupPath: string, dirNames: string[]): string[] { return existing; } +function shouldPreserveOpenClawManagedExtensions( + manifest: RebuildManifest, + dir: string, + localDirs: readonly string[], +): boolean { + return ( + localDirs.includes("extensions") && + (manifest.agentType === "openclaw" || dir.replace(/\/+$/, "") === "/sandbox/.openclaw") + ); +} + +function buildRestoreTarArgs( + backupPath: string, + localDirs: readonly string[], + preserveManagedExtensions: boolean, +): string[] { + const args = ["-cf", "-", "-C", backupPath]; + if (preserveManagedExtensions) { + for (const extensionName of OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS) { + args.push("--exclude", `extensions/${extensionName}`); + } + } + args.push("--", ...localDirs); + return args; +} + +function buildOpenClawExtensionsCleanupCommand(dir: string): string { + const extensionsDir = `${dir}/extensions`; + const quotedExtensionsDir = shellQuote(extensionsDir); + const validationCommands = OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS.map((extensionName) => { + const managedPath = `${extensionsDir}/${extensionName}`; + return ( + `p=${shellQuote(managedPath)}; ` + + 'if [ -e "$p" ] && { [ ! -d "$p" ] || [ -L "$p" ]; }; then ' + + 'echo "refusing to preserve unsafe managed extension: $p" >&2; exit 20; fi' + ); + }).join("; "); + const validateManagedPaths = `{ ${validationCommands}; }`; + const preservedNames = OPENCLAW_IMAGE_MANAGED_EXTENSION_DIRS.map( + (extensionName) => `! -name ${shellQuote(extensionName)}`, + ).join(" "); + + return [ + `mkdir -p -- ${quotedExtensionsDir}`, + validateManagedPaths, + `find ${quotedExtensionsDir} -mindepth 1 -maxdepth 1 ${preservedNames} -exec rm -rf -- {} +`, + ].join(" && "); +} + +function buildRestoreCleanupCommand( + dir: string, + localDirs: readonly string[], + preserveManagedExtensions: boolean, +): string { + const commands: string[] = []; + for (const dirName of localDirs) { + if (preserveManagedExtensions && dirName === "extensions") continue; + commands.push(`rm -rf -- ${shellQuote(`${dir}/${dirName}`)}`); + } + if (preserveManagedExtensions) { + commands.push(buildOpenClawExtensionsCleanupCommand(dir)); + } + return commands.length > 0 ? commands.join(" && ") : ":"; +} + function normalizeStateFileSpec(spec: AgentStateFile | StateFileSpec): StateFileSpec | null { const normalized = normalizeStateFilePath(spec.path); if (!normalized) return null; @@ -1305,11 +1371,20 @@ export function restoreSandboxState(sandboxName: string, backupPath: string): Re if (localDirs.length > 0) { // Upload via tar pipe // NC-2227-04: Removed -h flag from restore as well — no symlink following. - const tarResult = spawnSync("tar", ["-cf", "-", "-C", backupPath, ...localDirs], { - stdio: ["ignore", "pipe", "pipe"], - timeout: 60000, - maxBuffer: 256 * 1024 * 1024, - }); + const preserveManagedExtensions = shouldPreserveOpenClawManagedExtensions( + manifest, + dir, + localDirs, + ); + const tarResult = spawnSync( + "tar", + buildRestoreTarArgs(backupPath, localDirs, preserveManagedExtensions), + { + stdio: ["ignore", "pipe", "pipe"], + timeout: 60000, + maxBuffer: 256 * 1024 * 1024, + }, + ); if (tarResult.status !== 0 || !tarResult.stdout) { return { @@ -1321,9 +1396,12 @@ export function restoreSandboxState(sandboxName: string, backupPath: string): Re }; } - // Remove existing state dirs before extracting so stale files from - // later snapshots don't persist after restoring an earlier one. - const rmCmd = localDirs.map((d) => `rm -rf -- ${shellQuote(`${dir}/${d}`)}`).join(" && "); + // Remove existing state dirs before extracting so stale files from later + // snapshots don't persist after restoring an earlier one. OpenClaw's + // image-managed extensions are preserved from the freshly built image and + // excluded from the restore tar; only user/non-managed extension entries + // are cleared and restored from the backup. + const rmCmd = buildRestoreCleanupCommand(dir, localDirs, preserveManagedExtensions); _log(`Cleaning target dirs before restore: ${rmCmd}`); const rmResult = spawnSync("ssh", [...sshArgs(configFile, sandboxName), rmCmd], { stdio: ["ignore", "pipe", "pipe"], diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index dcdef43c5d1..86b6528b814 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -258,6 +258,75 @@ describe("sandbox provisioning: base runtime tools", () => { }); }); +describe("sandbox provisioning: WeChat OpenClaw extension", () => { + function runWechatExtensionBlock({ preinstall }: { preinstall: boolean }) { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wechat-extension-")); + const sandboxRoot = path.join(tmp, "sandbox"); + const extensionDir = path.join( + sandboxRoot, + ".openclaw", + "extensions", + "openclaw-weixin", + ); + const command = dockerRunCommandBetween( + dockerfile, + "# Ensure the WeChat OpenClaw extension exists in the final image", + "# Generate openclaw.json from environment variables", + ).replaceAll("/sandbox", sandboxRoot); + + try { + fs.mkdirSync(path.dirname(extensionDir), { recursive: true }); + if (preinstall) fs.mkdirSync(extensionDir, { recursive: true }); + + const result = runLoggedDockerShell(command, tmp, [ + [ + "openclaw() {", + ' printf "openclaw %s\\n" "$*" >> "$call_log";', + ' if [ "${1:-}" = "plugins" ] && [ "${2:-}" = "install" ]; then', + ` mkdir -p ${JSON.stringify(extensionDir)};`, + " fi;", + "}", + ].join("\n"), + ]); + return { ...result, tmp, extensionDir }; + } catch (error) { + fs.rmSync(tmp, { recursive: true, force: true }); + throw error; + } + } + + it("installs openclaw-weixin in the final image when a stale base lacks it", () => { + const { result, calls, tmp, extensionDir } = runWechatExtensionBlock({ preinstall: false }); + try { + expect(result.status, result.stderr).toBe(0); + expect(calls).toContain( + "openclaw plugins install @tencent-weixin/openclaw-weixin@2.4.2 --pin", + ); + expect(calls).toContain( + "openclaw config set plugins.entries.openclaw-weixin.enabled true", + ); + expect(fs.statSync(extensionDir).isDirectory()).toBe(true); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("keeps an existing openclaw-weixin extension and refreshes its config entry", () => { + const { result, calls, tmp, extensionDir } = runWechatExtensionBlock({ preinstall: true }); + try { + expect(result.status, result.stderr).toBe(0); + expect(calls).not.toContain("openclaw plugins install"); + expect(calls).toContain( + "openclaw config set plugins.entries.openclaw-weixin.enabled true", + ); + expect(fs.statSync(extensionDir).isDirectory()).toBe(true); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () => { it("normalizes copied blueprint permissions before non-root config generation", () => { const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index adfcc403d0d..288a3ae3984 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -564,7 +564,10 @@ process.exit(0); .map((line) => JSON.parse(line).cmd as string); const cleanupCommand = loggedCommands.find((cmd) => cmd.includes("rm -rf")); expect(cleanupCommand).toContain("/sandbox/.openclaw/workspace"); + expect(cleanupCommand).not.toContain("rm -rf -- /sandbox/.openclaw/extensions"); expect(cleanupCommand).toContain("/sandbox/.openclaw/extensions"); + expect(cleanupCommand).toContain("! -name 'nemoclaw'"); + expect(cleanupCommand).toContain("! -name 'openclaw-weixin'"); expect(cleanupCommand).not.toContain("/sandbox/.openclaw/agents"); } finally { if (oldOpenshell === undefined) { @@ -577,6 +580,123 @@ process.exit(0); } }); + it("preserves fresh image-managed OpenClaw extensions while restoring user extensions", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-extension-restore-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const openclawDir = path.join(fixture, "sandbox-root", ".openclaw"); + const sshLog = path.join(fixture, "ssh-log.jsonl"); + const extensionsDir = path.join(openclawDir, "extensions"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(path.join(extensionsDir, "nemoclaw"), { recursive: true }); + fs.mkdirSync(path.join(extensionsDir, "openclaw-weixin"), { recursive: true }); + fs.mkdirSync(path.join(extensionsDir, "stale-user-extension"), { recursive: true }); + fs.writeFileSync(path.join(extensionsDir, "nemoclaw", "marker.txt"), "fresh-nemoclaw\n"); + fs.writeFileSync(path.join(extensionsDir, "openclaw-weixin", "marker.txt"), "fresh-weixin\n"); + fs.writeFileSync( + path.join(extensionsDir, "stale-user-extension", "marker.txt"), + "stale\n", + ); + + const manifest = writeBackup("alpha", "2026-05-19T12-00-00-000Z", { + stateDirs: ["extensions"], + backedUpDirs: ["extensions"], + }); + const backupExtensionsDir = path.join(String(manifest.backupPath), "extensions"); + fs.mkdirSync(path.join(backupExtensionsDir, "nemoclaw"), { recursive: true }); + fs.mkdirSync(path.join(backupExtensionsDir, "openclaw-weixin"), { recursive: true }); + fs.mkdirSync(path.join(backupExtensionsDir, "user-extension"), { recursive: true }); + fs.writeFileSync(path.join(backupExtensionsDir, "nemoclaw", "marker.txt"), "old-nemoclaw\n"); + fs.writeFileSync( + path.join(backupExtensionsDir, "openclaw-weixin", "marker.txt"), + "old-weixin\n", + ); + fs.writeFileSync(path.join(backupExtensionsDir, "user-extension", "marker.txt"), "restored\n"); + + const openshell = writeFakeOpenshell(binDir); + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const cmd = process.argv[process.argv.length - 1] || ""; +fs.appendFileSync(${JSON.stringify(sshLog)}, JSON.stringify({ cmd }) + "\\n"); +function readStdin() { + const chunks = []; + for (;;) { + const buf = Buffer.alloc(65536); + const n = fs.readSync(0, buf, 0, buf.length, null); + if (n === 0) break; + chunks.push(buf.subarray(0, n)); + } + return Buffer.concat(chunks); +} +if (cmd.includes("/sandbox/.openclaw/extensions") && cmd.includes("-exec rm -rf")) { + const extensionsDir = ${JSON.stringify(extensionsDir)}; + fs.mkdirSync(extensionsDir, { recursive: true }); + for (const entry of fs.readdirSync(extensionsDir)) { + if (entry === "nemoclaw" || entry === "openclaw-weixin") continue; + fs.rmSync(path.join(extensionsDir, entry), { recursive: true, force: true }); + } + process.exit(0); +} +if (cmd.includes("tar --no-same-owner -xf -")) { + const r = spawnSync("tar", ["--no-same-owner", "-xf", "-", "-C", ${JSON.stringify(openclawDir)}], { + input: readStdin(), + stdio: ["pipe", "pipe", "pipe"], + }); + if (r.stdout) fs.writeSync(1, r.stdout); + if (r.stderr) fs.writeSync(2, r.stderr); + process.exit(r.status || 0); +} +if (cmd.includes("chown") || cmd.includes("[ -d ")) { + process.exit(0); +} +process.exit(0); +`, + ); + + writeOpenClawRegistry("alpha"); + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}${path.delimiter}${oldPath || ""}`; + + const restore = sandboxState.restoreSandboxState("alpha", String(manifest.backupPath)); + expect(restore.success).toBe(true); + expect(restore.restoredDirs).toEqual(["extensions"]); + expect( + fs.readFileSync(path.join(extensionsDir, "nemoclaw", "marker.txt"), "utf-8"), + ).toBe("fresh-nemoclaw\n"); + expect( + fs.readFileSync(path.join(extensionsDir, "openclaw-weixin", "marker.txt"), "utf-8"), + ).toBe("fresh-weixin\n"); + expect(fs.existsSync(path.join(extensionsDir, "stale-user-extension"))).toBe(false); + expect( + fs.readFileSync(path.join(extensionsDir, "user-extension", "marker.txt"), "utf-8"), + ).toBe("restored\n"); + + const loggedCommands = fs + .readFileSync(sshLog, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line).cmd as string); + const cleanupCommand = loggedCommands.find((cmd) => cmd.includes("/sandbox/.openclaw/extensions")); + expect(cleanupCommand).not.toContain("rm -rf -- /sandbox/.openclaw/extensions"); + expect(cleanupCommand).toContain("! -name 'nemoclaw'"); + expect(cleanupCommand).toContain("! -name 'openclaw-weixin'"); + } finally { + if (oldOpenshell === undefined) { + delete process.env.NEMOCLAW_OPENSHELL_BIN; + } else { + process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell; + } + process.env.PATH = oldPath; + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + it("accepts whitelisted npm symlinks under extensions/ during pre-backup audit", () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-whitelist-")); const oldPath = process.env.PATH;