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
11 changes: 11 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
Comment on lines +823 to +834

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard stdout before .includes() and emit an explicit “skipped” message for missing helper.

Line 828 can throw if stdout is undefined (you already treat it as optional on Line 825). Also, when the helper is absent, this path exits 0 but prints no user-facing status.

Suggested fix
     const seedWechatResult = executeSandboxCommand(sandboxName, seedWechatCommand);
+    const seedWechatStdout = seedWechatResult?.stdout || "";
+    const seedWechatHelperMissing = seedWechatStdout.includes("not present; skipping");
     log(
-      `seed-wechat-accounts.py: exit=${seedWechatResult?.status}, stdout=${(seedWechatResult?.stdout || "").substring(0, 200)}`,
+      `seed-wechat-accounts.py: exit=${seedWechatResult?.status}, stdout=${seedWechatStdout.substring(0, 200)}`,
     );
     if (seedWechatResult && seedWechatResult.status === 0) {
-      if (!seedWechatResult.stdout.includes("not present; skipping")) {
+      if (seedWechatHelperMissing) {
+        console.log(`  ${D}WeChat account seed helper not present; skipping${R}`);
+      } else {
         console.log(`  ${G}\u2713${R} WeChat account seed reapplied`);
       }
     } else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/actions/sandbox/rebuild.ts` around lines 823 - 834, The code calls
seedWechatResult.stdout.includes(...) without guaranteeing stdout is defined and
also shows no user-facing message when the seed helper is absent; update the
seed handling in the seedWechatResult block (the result of executeSandboxCommand
with seedWechatCommand) to first guard stdout (e.g., check typeof
seedWechatResult?.stdout === "string" or seedWechatResult?.stdout != null)
before calling .includes, and when status === 0 but stdout is missing/undefined
emit an explicit "WeChat account seed skipped (helper missing)" or similar
console.log message so users see the skipped state; keep the existing success
message (console.log with G check) when stdout exists and does not contain "not
present; skipping".

}
}
// Hermes: no explicit post-restore step needed. Hermes's SessionDB._init_schema()
// auto-migrates state.db (SQLite) on first connection via sequential ALTER TABLE
Expand Down
94 changes: 86 additions & 8 deletions src/lib/state/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ const AUDIT_SYMLINK_WHITELIST: ReadonlyMap<string, string> = 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("/");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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"],
Expand Down
69 changes: 69 additions & 0 deletions test/sandbox-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
120 changes: 120 additions & 0 deletions test/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
Loading