feat: upgrade openclaw to 4.26 (#899)

Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
This commit is contained in:
Haze
2026-04-28 15:04:19 +08:00
committed by GitHub
parent e95c72f79a
commit 9c930b2198
23 changed files with 2190 additions and 2241 deletions

View File

@@ -19,9 +19,13 @@
* @mariozechner/clipboard).
*/
const { cpSync, existsSync, readdirSync, rmSync, statSync, mkdirSync, realpathSync } = require('fs');
const { createHash } = require('crypto');
const { cpSync, existsSync, readdirSync, rmSync, statSync, mkdirSync, realpathSync, readFileSync, renameSync, writeFileSync } = require('fs');
const { join, dirname, basename, relative } = require('path');
const RUNTIME_DEPS_MANIFEST = 'clawx-runtime-deps.json';
const OPENCLAW_RUNTIME_READY_MARKER = '.clawx-runtime-ready.json';
// On Windows, paths in pnpm's virtual store can exceed the default MAX_PATH
// limit (260 chars). Node.js 18.17+ respects the system LongPathsEnabled
// registry key, but as a safety net we normalize paths to use the \\?\ prefix
@@ -459,10 +463,19 @@ function bundlePlugin(nodeModulesRoot, npmName, destDir) {
let realPluginPath;
try { realPluginPath = realpathSync(pkgPath); } catch { realPluginPath = pkgPath; }
function shouldCopyNodePackageEntry(src) {
const base = basename(src);
return base !== '.vscode' && base !== '.idea';
}
// Copy plugin package itself
if (existsSync(normWin(destDir))) rmSync(normWin(destDir), { recursive: true, force: true });
mkdirSync(normWin(destDir), { recursive: true });
cpSync(normWin(realPluginPath), normWin(destDir), { recursive: true, dereference: true });
cpSync(normWin(realPluginPath), normWin(destDir), {
recursive: true,
dereference: true,
filter: shouldCopyNodePackageEntry,
});
// Collect transitive deps via pnpm virtual store BFS
const collected = new Map();
@@ -515,7 +528,11 @@ function bundlePlugin(nodeModulesRoot, npmName, destDir) {
const d = join(destNM, pkgName);
try {
mkdirSync(normWin(dirname(d)), { recursive: true });
cpSync(normWin(rp), normWin(d), { recursive: true, dereference: true });
cpSync(normWin(rp), normWin(d), {
recursive: true,
dereference: true,
filter: shouldCopyNodePackageEntry,
});
count++;
} catch (e) {
console.warn(`[after-pack] Skipped dep ${pkgName}: ${e.message}`);
@@ -525,6 +542,132 @@ function bundlePlugin(nodeModulesRoot, npmName, destDir) {
return true;
}
function hasPluginRuntimeDeps(pluginDestDir) {
const pluginNM = join(pluginDestDir, 'node_modules');
if (!existsSync(normWin(pluginNM))) return false;
try {
return readdirSync(normWin(pluginNM)).some((entry) => entry !== '.bin');
} catch {
return false;
}
}
function collectPluginRuntimeDeps(pluginDestDir) {
try {
const pkg = JSON.parse(readFileSync(normWin(join(pluginDestDir, 'package.json')), 'utf8'));
return Object.keys({
...pkg.dependencies,
...pkg.optionalDependencies,
});
} catch {
return [];
}
}
function missingPluginRuntimeDeps(pluginDestDir) {
return collectPluginRuntimeDeps(pluginDestDir).filter((depName) => {
const depPackageJson = join(pluginDestDir, 'node_modules', ...depName.split('/'), 'package.json');
return !existsSync(normWin(depPackageJson));
});
}
function preparePackagedPluginMirror(nodeModulesRoot, npmName, pluginId, pluginDestDir, platform, arch) {
const manifestPath = join(pluginDestDir, 'openclaw.plugin.json');
if (!existsSync(normWin(manifestPath)) || !hasPluginRuntimeDeps(pluginDestDir) || missingPluginRuntimeDeps(pluginDestDir).length > 0) {
console.log(`[after-pack] Bundling plugin ${npmName} -> ${pluginDestDir}`);
const ok = bundlePlugin(nodeModulesRoot, npmName, pluginDestDir);
if (!ok) return;
} else {
console.log(`[after-pack] ✅ Plugin mirror already present: ${pluginDestDir}`);
}
const pluginNM = join(pluginDestDir, 'node_modules');
cleanupUnnecessaryFiles(pluginDestDir);
if (existsSync(normWin(pluginNM))) {
cleanupKoffi(pluginNM, platform, arch);
cleanupNativePlatformPackages(pluginNM, platform, arch);
}
patchPluginIds(pluginDestDir, pluginId);
}
function validateRuntimeDepsManifest(openclawRoot) {
const manifestPath = join(openclawRoot, RUNTIME_DEPS_MANIFEST);
if (!existsSync(normWin(manifestPath))) {
throw new Error(`[after-pack] Missing ${RUNTIME_DEPS_MANIFEST} in packaged OpenClaw resources`);
}
const manifest = JSON.parse(readFileSync(normWin(manifestPath), 'utf8'));
const plugins = manifest && typeof manifest === 'object' ? manifest.plugins : null;
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) {
throw new Error(`[after-pack] Invalid ${RUNTIME_DEPS_MANIFEST}: missing plugins object`);
}
const missing = [];
for (const [pluginId, deps] of Object.entries(plugins)) {
if (!Array.isArray(deps)) continue;
for (const dep of deps) {
if (!dep || typeof dep.name !== 'string') continue;
const depPackageJson = join(openclawRoot, 'node_modules', ...dep.name.split('/'), 'package.json');
if (!existsSync(normWin(depPackageJson))) {
missing.push(`${pluginId}:${dep.name}`);
}
}
}
if (missing.length > 0) {
throw new Error(`[after-pack] Missing packaged OpenClaw runtime deps: ${missing.join(', ')}`);
}
console.log(`[after-pack] ✅ Verified ${RUNTIME_DEPS_MANIFEST}.`);
}
function hashOpenClawRuntime(openclawRoot) {
const packageJsonPath = join(openclawRoot, 'package.json');
const manifestPath = join(openclawRoot, RUNTIME_DEPS_MANIFEST);
const packageJsonRaw = readFileSync(normWin(packageJsonPath), 'utf8');
const manifestRaw = existsSync(normWin(manifestPath)) ? readFileSync(normWin(manifestPath), 'utf8') : '';
const packageJson = JSON.parse(packageJsonRaw);
const version = packageJson.version || 'unknown';
const manifestHash = createHash('sha256')
.update(packageJsonRaw)
.update('\0')
.update(manifestRaw)
.digest('hex')
.slice(0, 12);
const safeVersion = String(version).replace(/[^a-zA-Z0-9._-]/g, '_');
return {
key: `openclaw-${safeVersion}-${manifestHash}`,
version,
manifestHash,
};
}
function stageOpenClawRuntimeForPackagedApp(resourcesDir, openclawRoot) {
if (!existsSync(normWin(openclawRoot))) {
throw new Error(`[after-pack] Cannot stage OpenClaw runtime; missing ${openclawRoot}`);
}
const { key, version, manifestHash } = hashOpenClawRuntime(openclawRoot);
const runtimeRoot = join(resourcesDir, 'openclaw-runtime');
const targetDir = join(runtimeRoot, key);
mkdirSync(normWin(runtimeRoot), { recursive: true });
rmSync(normWin(targetDir), { recursive: true, force: true });
renameSync(normWin(openclawRoot), normWin(targetDir));
writeFileSync(
normWin(join(targetDir, OPENCLAW_RUNTIME_READY_MARKER)),
`${JSON.stringify({
version,
manifestHash,
source: 'after-pack',
createdAt: new Date().toISOString(),
}, null, 2)}\n`,
'utf8',
);
validateRuntimeDepsManifest(targetDir);
console.log(`[after-pack] ✅ Staged OpenClaw runtime at ${targetDir}`);
return targetDir;
}
// ── Main hook ────────────────────────────────────────────────────────────────
exports.default = async function afterPack(context) {
@@ -562,6 +705,7 @@ exports.default = async function afterPack(context) {
console.log(`[after-pack] Copying ${depCount} openclaw dependencies to ${dest} ...`);
cpSync(src, dest, { recursive: true });
console.log('[after-pack] ✅ openclaw node_modules copied.');
validateRuntimeDepsManifest(openclawRoot);
// Patch broken modules whose CJS transpiled output sets module.exports = undefined,
// causing TypeError in Node.js 22+ ESM interop.
@@ -582,29 +726,22 @@ exports.default = async function afterPack(context) {
mkdirSync(pluginsDestRoot, { recursive: true });
for (const { npmName, pluginId } of BUNDLED_PLUGINS) {
const pluginDestDir = join(pluginsDestRoot, pluginId);
console.log(`[after-pack] Bundling plugin ${npmName} -> ${pluginDestDir}`);
const ok = bundlePlugin(nodeModulesRoot, npmName, pluginDestDir);
if (ok) {
const pluginNM = join(pluginDestDir, 'node_modules');
cleanupUnnecessaryFiles(pluginDestDir);
if (existsSync(pluginNM)) {
cleanupKoffi(pluginNM, platform, arch);
cleanupNativePlatformPackages(pluginNM, platform, arch);
}
// Fix hardcoded plugin ID mismatches in compiled JS
patchPluginIds(pluginDestDir, pluginId);
preparePackagedPluginMirror(nodeModulesRoot, npmName, pluginId, pluginDestDir, platform, arch);
const manifestPath = join(pluginDestDir, 'openclaw.plugin.json');
if (!existsSync(normWin(manifestPath))) {
throw new Error(`[after-pack] Missing packaged plugin mirror: ${pluginId} (${npmName})`);
}
const missingRuntimeDeps = missingPluginRuntimeDeps(pluginDestDir);
if (missingRuntimeDeps.length > 0) {
throw new Error(`[after-pack] Missing packaged plugin runtime deps for ${pluginId}: ${missingRuntimeDeps.join(', ')}`);
}
}
// 1.2 Copy built-in extension node_modules that electron-builder skipped.
// OpenClaw 3.31+ ships built-in extensions (discord, qqbot, etc.) under
// dist/extensions/<ext>/node_modules/. These are skipped by extraResources
// because .gitignore contains "node_modules/".
//
// Extension code is loaded via shared chunks in dist/ (e.g. outbound-*.js)
// which resolve modules from the top-level openclaw/node_modules/, NOT from
// the extension's own node_modules/. So we must merge extension deps into
// the top-level node_modules/ as well.
// 1.2 Legacy safety net for build/openclaw bundles that still contain nested
// built-in extension node_modules. The current bundle-openclaw.mjs skips
// these nested directories and merges their packages into the top-level
// OpenClaw node_modules instead, which is where shared dist chunks resolve
// bare imports from at runtime.
const buildExtDir = join(__dirname, '..', 'build', 'openclaw', 'dist', 'extensions');
const packExtDir = join(openclawRoot, 'dist', 'extensions');
if (existsSync(buildExtDir)) {
@@ -670,6 +807,15 @@ exports.default = async function afterPack(context) {
console.log(`[after-pack] ✅ Removed ${nativeRemoved} non-target native platform packages.`);
}
// 4.1 Move the finalized OpenClaw tree into the shape OpenClaw already
// recognizes as an external runtime root:
// resources/openclaw-runtime/openclaw-<version>-<hash>
//
// At runtime ClawX sets OPENCLAW_PLUGIN_STAGE_DIR to resources/openclaw-runtime
// and launches from the staged openclaw-* directory. This avoids a first-run
// copy while still preventing OpenClaw from running npm install.
stageOpenClawRuntimeForPackagedApp(resourcesDir, openclawRoot);
// 5. Patch lru-cache in app.asar.unpacked
//
// Production dependencies (electron-updater → semver → lru-cache@6,

View File

@@ -93,13 +93,22 @@ function bundleOnePlugin({ npmName, pluginId }) {
echo`📦 Bundling plugin ${npmName} -> ${outputDir}`;
function shouldCopyNodePackageEntry(src) {
const base = path.basename(src);
return base !== '.vscode' && base !== '.idea';
}
if (fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true, force: true });
}
fs.mkdirSync(outputDir, { recursive: true });
// 1) Copy plugin package itself
fs.cpSync(realPluginPath, outputDir, { recursive: true, dereference: true });
fs.cpSync(realPluginPath, outputDir, {
recursive: true,
dereference: true,
filter: shouldCopyNodePackageEntry,
});
// 2) Collect transitive deps from pnpm virtual store
const collected = new Map();
@@ -160,7 +169,11 @@ function bundleOnePlugin({ npmName, pluginId }) {
const dest = path.join(outputNodeModules, pkgName);
try {
fs.mkdirSync(normWin(path.dirname(dest)), { recursive: true });
fs.cpSync(normWin(realPath), normWin(dest), { recursive: true, dereference: true });
fs.cpSync(normWin(realPath), normWin(dest), {
recursive: true,
dereference: true,
filter: shouldCopyNodePackageEntry,
});
copiedCount++;
} catch (err) {
echo` ⚠️ Skipped ${pkgName}: ${err.message}`;

View File

@@ -17,10 +17,13 @@
*/
import 'zx/globals';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const OUTPUT = path.join(ROOT, 'build', 'openclaw');
const NODE_MODULES = path.join(ROOT, 'node_modules');
const RUNTIME_DEPS_MANIFEST = 'clawx-runtime-deps.json';
// On Windows, pnpm virtual store paths can exceed MAX_PATH (260 chars).
function normWin(p) {
@@ -40,6 +43,27 @@ if (!fs.existsSync(openclawLink)) {
const openclawReal = fs.realpathSync(openclawLink);
echo` openclaw resolved: ${openclawReal}`;
const extensionsDir = path.join(openclawReal, 'dist', 'extensions');
function shouldCopyOpenClawPackageEntry(src) {
const rel = path.relative(openclawReal, src);
if (!rel || rel.startsWith('..')) return true;
const parts = rel.split(path.sep);
if (parts[0] === 'dist' && parts[1] === 'extensions') {
const nodeModulesIndex = parts.indexOf('node_modules');
if (nodeModulesIndex >= 0) {
return false;
}
}
for (let i = 0; i < parts.length - 1; i++) {
if (parts[i] === 'node_modules' && parts[i + 1] === '.bin') {
return false;
}
}
return true;
}
// 2. Clean and create output directory
if (fs.existsSync(OUTPUT)) {
@@ -49,7 +73,11 @@ fs.mkdirSync(OUTPUT, { recursive: true });
// 3. Copy openclaw package itself to OUTPUT root
echo` Copying openclaw package...`;
fs.cpSync(openclawReal, OUTPUT, { recursive: true, dereference: true });
fs.cpSync(openclawReal, OUTPUT, {
recursive: true,
dereference: true,
filter: shouldCopyOpenClawPackageEntry,
});
// 4. Recursively collect ALL transitive dependencies via pnpm virtual store BFS
//
@@ -188,9 +216,109 @@ echo` Skipped ${skippedDevCount} dev-only package references`;
// then BFS its transitive deps exactly like we did for openclaw above.
const EXTRA_BUNDLED_PACKAGES = [
'@whiskeysockets/baileys', // WhatsApp channel (was a dep of old clawdbot, not openclaw)
'@larksuiteoapi/node-sdk', // Fallback for Feishu plugin setup/doctor module resolution
'qrcode-terminal', // QR rendering used by WhatsApp/WeChat login helpers
];
const BUNDLED_EXTENSION_RUNTIME_DEP_PLUGIN_IDS = [
'acpx',
'bonjour',
'browser',
'discord',
'qqbot',
'telegram',
];
function readJsonFile(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
function collectBundledExtensionRuntimeDeps(extensionsRoot) {
const depsByPlugin = {};
for (const pluginId of BUNDLED_EXTENSION_RUNTIME_DEP_PLUGIN_IDS) {
const packageJson = readJsonFile(path.join(extensionsRoot, pluginId, 'package.json'));
if (!packageJson || typeof packageJson !== 'object') {
echo`❌ Bundled extension package.json not found for ${pluginId}`;
process.exit(1);
}
const depsByName = {};
for (const deps of [packageJson.dependencies, packageJson.optionalDependencies]) {
if (!deps || typeof deps !== 'object' || Array.isArray(deps)) continue;
for (const [name, version] of Object.entries(deps)) {
depsByName[name] = String(version);
}
}
depsByPlugin[pluginId] = Object.entries(depsByName)
.map(([name, version]) => ({ name, version }))
.sort((left, right) => left.name.localeCompare(right.name));
}
return depsByPlugin;
}
function flattenRuntimeDeps(depsByPlugin) {
return [...new Set(Object.values(depsByPlugin).flat().map(dep => dep.name))]
.sort((a, b) => a.localeCompare(b));
}
function readBundledPackageVersion(nodeModulesDir, pkgName) {
const packageJson = readJsonFile(path.join(nodeModulesDir, ...pkgName.split('/'), 'package.json'));
return typeof packageJson?.version === 'string' ? packageJson.version : null;
}
function writeRuntimeDepsManifest(outputDir, depsByPlugin) {
const nodeModulesDir = path.join(outputDir, 'node_modules');
const manifest = {
generatedBy: 'scripts/bundle-openclaw.mjs',
packageRoot: '.',
nodeModulesRoot: 'node_modules',
plugins: {},
};
const missing = [];
for (const [pluginId, deps] of Object.entries(depsByPlugin)) {
manifest.plugins[pluginId] = deps.map((dep) => {
const installedVersion = readBundledPackageVersion(nodeModulesDir, dep.name);
const present = Boolean(installedVersion);
if (!present) {
missing.push(`${pluginId}:${dep.name}@${dep.version}`);
}
return {
name: dep.name,
version: dep.version,
installedVersion,
present,
};
});
}
if (missing.length > 0) {
echo`❌ Missing bundled extension runtime deps: ${missing.join(', ')}`;
process.exit(1);
}
fs.writeFileSync(
path.join(outputDir, RUNTIME_DEPS_MANIFEST),
`${JSON.stringify(manifest, null, 2)}\n`,
'utf8',
);
echo` Wrote ${RUNTIME_DEPS_MANIFEST} for ${Object.keys(manifest.plugins).length} bundled extension(s)`;
}
const bundledExtensionRuntimeDeps = collectBundledExtensionRuntimeDeps(extensionsDir);
const bundledExtensionRuntimePackages = flattenRuntimeDeps(bundledExtensionRuntimeDeps);
for (const pkgName of bundledExtensionRuntimePackages) {
if (!EXTRA_BUNDLED_PACKAGES.includes(pkgName)) {
EXTRA_BUNDLED_PACKAGES.push(pkgName);
}
}
const preferredBundledPackages = new Set(EXTRA_BUNDLED_PACKAGES);
let extraCount = 0;
const preferredBundledPackageRealPaths = new Set();
for (const pkgName of EXTRA_BUNDLED_PACKAGES) {
const pkgLink = path.join(NODE_MODULES, ...pkgName.split('/'));
if (!fs.existsSync(pkgLink)) {
@@ -200,6 +328,7 @@ for (const pkgName of EXTRA_BUNDLED_PACKAGES) {
let pkgReal;
try { pkgReal = fs.realpathSync(pkgLink); } catch { continue; }
preferredBundledPackageRealPaths.add(pkgReal);
if (!collected.has(pkgReal)) {
collected.set(pkgReal, pkgName);
@@ -247,8 +376,22 @@ fs.mkdirSync(outputNodeModules, { recursive: true });
const copiedNames = new Set(); // Track package names already copied
let copiedCount = 0;
let skippedDupes = 0;
const collectedEntries = [...collected].sort(([leftRealPath, leftName], [rightRealPath, rightName]) => {
const leftPreferredRealPath = preferredBundledPackageRealPaths.has(leftRealPath);
const rightPreferredRealPath = preferredBundledPackageRealPaths.has(rightRealPath);
if (leftPreferredRealPath !== rightPreferredRealPath) return leftPreferredRealPath ? -1 : 1;
const leftPreferred = preferredBundledPackages.has(leftName);
const rightPreferred = preferredBundledPackages.has(rightName);
if (leftPreferred === rightPreferred) return 0;
return leftPreferred ? -1 : 1;
});
for (const [realPath, pkgName] of collected) {
function shouldCopyNodePackageEntry(src) {
const base = path.basename(src);
return base !== '.vscode' && base !== '.idea';
}
for (const [realPath, pkgName] of collectedEntries) {
if (copiedNames.has(pkgName)) {
skippedDupes++;
continue; // Keep the first version (closer to openclaw in dep tree)
@@ -259,7 +402,11 @@ for (const [realPath, pkgName] of collected) {
try {
fs.mkdirSync(normWin(path.dirname(dest)), { recursive: true });
fs.cpSync(normWin(realPath), normWin(dest), { recursive: true, dereference: true });
fs.cpSync(normWin(realPath), normWin(dest), {
recursive: true,
dereference: true,
filter: shouldCopyNodePackageEntry,
});
copiedCount++;
} catch (err) {
echo` ⚠️ Skipped ${pkgName}: ${err.message}`;
@@ -279,7 +426,6 @@ for (const [realPath, pkgName] of collected) {
// Fix: copy extension deps into the top-level node_modules/ so they are
// resolvable from shared chunks. Skip-if-exists preserves version priority
// (openclaw's own deps take precedence over extension deps).
const extensionsDir = path.join(OUTPUT, 'dist', 'extensions');
let mergedExtCount = 0;
if (fs.existsSync(extensionsDir)) {
for (const extEntry of fs.readdirSync(extensionsDir, { withFileTypes: true })) {
@@ -325,6 +471,8 @@ if (mergedExtCount > 0) {
echo` Merged ${mergedExtCount} extension packages into top-level node_modules`;
}
writeRuntimeDepsManifest(OUTPUT, bundledExtensionRuntimeDeps);
// 6. Clean up the bundle to reduce package size
//
// This removes platform-agnostic waste: dev artifacts, docs, source maps,
@@ -470,7 +618,7 @@ function cleanupBundle(outputDir) {
'node_modules/koffi/src',
'node_modules/koffi/vendor',
'node_modules/koffi/doc',
'extensions/feishu', // Removed in favor of official @larksuite/openclaw-lark plugin
'dist/extensions/feishu', // Removed in favor of official @larksuite/openclaw-lark plugin
];
for (const rel of LARGE_REMOVALS) {
if (rmSafe(path.join(outputDir, rel))) removedCount++;
@@ -712,12 +860,20 @@ function patchBundledRuntime(outputDir) {
for (const patch of replacePatches) {
const target = patch.target();
if (!target || !fs.existsSync(target)) {
if (patch.required) {
echo`❌ Required patch failed for ${patch.label}: target file not found`;
process.exit(1);
}
echo` ⚠️ Skipped patch for ${patch.label}: target file not found`;
continue;
}
const current = fs.readFileSync(target, 'utf8');
if (!current.includes(patch.search)) {
if (patch.required) {
echo`❌ Required patch failed for ${patch.label}: expected source snippet not found`;
process.exit(1);
}
echo` ⚠️ Skipped patch for ${patch.label}: expected source snippet not found`;
continue;
}