feat: bundle plugin npm dependencies

This commit is contained in:
Peter Steinberger
2026-05-21 19:43:46 +01:00
parent 0d28040092
commit de022bb69d
11 changed files with 236 additions and 45 deletions

View File

@@ -28,7 +28,7 @@ Docs: https://docs.openclaw.ai
- QA-Lab: include an opt-in `update.run` package self-upgrade sentinel for destructive latest-package recovery checks.
- QA-Lab: add Codex plugin lifecycle and auth-profile fixture coverage for missing installs, pinned-version drift, first-turn install ordering, and doctor migration safety. (#80323, refs #80174) Thanks @100yenadmin.
- Models/perf: pre-warm the provider auth-state map at gateway startup so `/models` and every model-listing call short-circuits the per-provider plugin / external-CLI discovery on the hot path. Per-call cost drops from ~20 s to ~5 ms (~4,100×); the one-time startup warm resets and re-warms after hot reloads. (#84816) Thanks @sjf.
- Release/security: ship the root npm package and OpenClaw-owned npm plugins with generated shrinkwrap and require review for lockfile/shrinkwrap changes so published installs use locked dependency graphs.
- Release/security: ship the root npm package and OpenClaw-owned npm plugins with generated shrinkwrap, support bundled plugin runtime dependencies for suitable plugin tarballs, and require review for lockfile/shrinkwrap changes so published installs use locked dependency graphs.
- Tests/perf: isolate doctor core health check unit coverage from real skills/workspace discovery so `doctor-core-checks` no longer dominates unit perf while keeping one real skills-readiness smoke. (#84493) Thanks @frankekn.
### Fixes

View File

@@ -59,7 +59,9 @@ OpenClaw source checkouts use `pnpm-lock.yaml`. The published `openclaw` npm
package and OpenClaw-owned npm plugin packages include `npm-shrinkwrap.json`,
npm's publishable dependency lockfile, so package installs use the reviewed
transitive dependency graph from the release instead of resolving a fresh graph
at install time.
at install time. OpenClaw-owned npm plugin packages also publish with
`bundleDependencies`, so their runtime dependency files are carried in the
plugin tarball instead of depending only on install-time resolution.
This is a supply-chain hardening measure:
@@ -67,6 +69,7 @@ This is a supply-chain hardening measure:
- transitive dependency updates become visible review surfaces;
- the package tarball contains the dependency graph that release validators
checked;
- OpenClaw-owned plugin tarballs contain the dependency files from that graph;
- `package-lock.json` stays out of the published package, because npm does not
treat it as the publishable lock contract.
@@ -87,10 +90,11 @@ Use `pnpm deps:shrinkwrap:root:generate` and
`pnpm deps:shrinkwrap:root:check` only when you intentionally want to refresh
the root `openclaw` package without touching plugin packages.
Review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, and any `package-lock.json`
diff as security-sensitive. The package validators require shrinkwrap in new
root package tarballs and the plugin npm publish path checks plugin-local
shrinkwrap before packing or publishing. Package validators reject
Review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, bundled plugin dependency
payloads, and any `package-lock.json` diff as security-sensitive. The package
validators require shrinkwrap in new root package tarballs and the plugin npm
publish path checks plugin-local shrinkwrap, installs package-local bundled
dependencies, and then packs or publishes. Package validators reject
`package-lock.json`.
To inspect a published package:
@@ -106,6 +110,7 @@ the same tar entry:
```bash
npm pack @openclaw/discord@<version> --json --pack-destination /tmp/openclaw-plugin-pack
tar -tf /tmp/openclaw-plugin-pack/openclaw-discord-<version>.tgz | grep '^package/npm-shrinkwrap.json$'
tar -tf /tmp/openclaw-plugin-pack/openclaw-discord-<version>.tgz | grep '^package/node_modules/'
```
Background: [npm-shrinkwrap.json](https://docs.npmjs.com/cli/v11/configuring-npm/npm-shrinkwrap-json).

View File

@@ -74,6 +74,13 @@ policy, and writes `extensions/<id>/npm-shrinkwrap.json` for each
OpenClaw does not require it for community packages, but npm will respect it
when present.
OpenClaw-owned npm plugin packages also publish with `bundleDependencies`. The
npm publish path overlays `bundleDependencies: true`, removes dev-only
workspace metadata from the published package manifest, runs a script-free npm
install for package-local runtime dependencies, then packs or publishes the
plugin tarball with those dependency files included. The root `openclaw`
package does not bundle its full dependency tree.
Plugins that import `openclaw/plugin-sdk/*` declare `openclaw` as a peer
dependency. OpenClaw does not let npm install a separate registry copy of the
host package into the managed root, because stale host packages can affect npm

View File

@@ -1521,9 +1521,9 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"

View File

@@ -1258,9 +1258,9 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"

View File

@@ -1830,9 +1830,9 @@
}
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"

View File

@@ -356,9 +356,9 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"

6
npm-shrinkwrap.json generated
View File

@@ -4470,9 +4470,9 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"optional": true,
"bin": {

View File

@@ -108,6 +108,70 @@ function assertPluginNpmRuntimeBuildExists(plan) {
assertPackageFilesDoNotExcludeRequiredRuntimeArtifacts(plan);
}
function hasPackageRuntimeDependencies(packageJson) {
return (
Object.keys(packageJson.dependencies ?? {}).length > 0 ||
Object.keys(packageJson.optionalDependencies ?? {}).length > 0
);
}
function shouldBundleDependencies(value) {
return value === true || value === "1" || value === "true";
}
function installPackageLocalBundledDependencies(params) {
const packageJson = params.packageJson;
if (packageJson.bundleDependencies !== true || !hasPackageRuntimeDependencies(packageJson)) {
return () => {};
}
const shrinkwrapPath = path.join(params.packageDir, "npm-shrinkwrap.json");
if (!fs.existsSync(shrinkwrapPath)) {
throw new Error(
`package-local bundled dependency install requires npm-shrinkwrap.json for ${params.pluginDir}`,
);
}
const nodeModulesPath = path.join(params.packageDir, "node_modules");
if (fs.existsSync(nodeModulesPath)) {
throw new Error(
`package-local bundled dependency install refuses to replace existing node_modules for ${params.pluginDir}`,
);
}
console.error(`[plugin-npm-publish] installing bundled dependencies for ${params.pluginDir}`);
const result = spawnSync(
"npm",
[
"install",
"--omit=dev",
"--omit=peer",
"--legacy-peer-deps",
"--ignore-scripts",
"--no-audit",
"--no-fund",
"--package-lock=false",
"--loglevel=error",
],
{
cwd: params.packageDir,
env: process.env,
stdio: ["ignore", "ignore", "inherit"],
},
);
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
throw new Error(
`package-local bundled dependency install failed for ${params.pluginDir} with exit ${result.status ?? 1}`,
);
}
return () => {
fs.rmSync(nodeModulesPath, { recursive: true, force: true });
};
}
export function resolveAugmentedPluginNpmPackageJson(params) {
const repoRoot = path.resolve(params.repoRoot ?? ".");
const packageDir = resolvePackageDir(repoRoot, params.packageDir);
@@ -147,6 +211,11 @@ export function resolveAugmentedPluginNpmPackageJson(params) {
...(plan.runtimeSetupEntry ? { runtimeSetupEntry: plan.runtimeSetupEntry } : {}),
},
};
if (shouldBundleDependencies(params.bundleDependencies)) {
packageJson.bundleDependencies = true;
delete packageJson.bundledDependencies;
delete packageJson.devDependencies;
}
const changed = JSON.stringify(packageJson) !== JSON.stringify(plan.packageJson);
return {
packageJsonPath,
@@ -155,6 +224,7 @@ export function resolveAugmentedPluginNpmPackageJson(params) {
changed,
packageJson,
pluginDir: plan.pluginDir,
bundleDependencies: shouldBundleDependencies(params.bundleDependencies),
reason: changed ? "package-local-runtime" : "unchanged",
};
}
@@ -290,6 +360,7 @@ export function resolveAugmentedPluginNpmManifest(params) {
export function withAugmentedPluginNpmManifestForPackage(params, callback) {
const repoRoot = path.resolve(params.repoRoot ?? ".");
const packageDir = resolvePackageDir(repoRoot, params.packageDir);
const bundleDependencies = shouldBundleDependencies(params.bundleDependencies);
const resolvedManifest = resolveAugmentedPluginNpmManifest({
repoRoot,
packageDir,
@@ -297,6 +368,7 @@ export function withAugmentedPluginNpmManifestForPackage(params, callback) {
const resolvedPackageJson = resolveAugmentedPluginNpmPackageJson({
repoRoot,
packageDir,
bundleDependencies,
});
if (
@@ -332,7 +404,15 @@ export function withAugmentedPluginNpmManifestForPackage(params, callback) {
);
writeJsonFile(resolvedPackageJson.packageJsonPath, resolvedPackageJson.packageJson);
}
let cleanupBundledDependencies = () => {};
try {
if (bundleDependencies && resolvedPackageJson.packageJson) {
cleanupBundledDependencies = installPackageLocalBundledDependencies({
packageDir,
packageJson: resolvedPackageJson.packageJson,
pluginDir: resolvedPackageJson.pluginDir ?? path.basename(packageDir),
});
}
return callback({
...resolvedManifest,
packageDir,
@@ -341,6 +421,7 @@ export function withAugmentedPluginNpmManifestForPackage(params, callback) {
packageJsonApplied: resolvedPackageJson.changed && Boolean(resolvedPackageJson.packageJson),
});
} finally {
cleanupBundledDependencies();
if (originalManifest !== undefined) {
fs.writeFileSync(resolvedManifest.manifestPath, originalManifest, "utf8");
}
@@ -372,17 +453,23 @@ function parseRunArgs(argv) {
function main(argv = process.argv.slice(2)) {
const { packageDir, command, args } = parseRunArgs(argv);
return withAugmentedPluginNpmManifestForPackage({ packageDir }, ({ packageDir: cwd }) => {
const result = spawnSync(command, args, {
cwd,
env: process.env,
stdio: "inherit",
});
if (result.error) {
throw result.error;
}
return result.status ?? 1;
});
return withAugmentedPluginNpmManifestForPackage(
{
packageDir,
bundleDependencies: process.env.OPENCLAW_PLUGIN_NPM_BUNDLE_DEPENDENCIES,
},
({ packageDir: cwd }) => {
const result = spawnSync(command, args, {
cwd,
env: process.env,
stdio: "inherit",
});
if (result.error) {
throw result.error;
}
return result.status ?? 1;
},
);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {

View File

@@ -140,7 +140,8 @@ build_package_runtime
check_package_shrinkwrap
if [[ "${mode}" == "--pack-dry-run" ]]; then
node scripts/lib/plugin-npm-package-manifest.mjs --run "${package_dir}" -- \
OPENCLAW_PLUGIN_NPM_BUNDLE_DEPENDENCIES=1 \
node scripts/lib/plugin-npm-package-manifest.mjs --run "${package_dir}" -- \
npm pack --dry-run --json --ignore-scripts
exit 0
fi
@@ -149,7 +150,8 @@ fi
cleanup_files=()
trap 'rm -f "${cleanup_files[@]}"' EXIT
run_with_manifest_overlay() {
node scripts/lib/plugin-npm-package-manifest.mjs --run "${package_dir}" -- "$@"
OPENCLAW_PLUGIN_NPM_BUNDLE_DEPENDENCIES=1 \
node scripts/lib/plugin-npm-package-manifest.mjs --run "${package_dir}" -- "$@"
}
publish_userconfig=""
if [[ -n "${publish_auth_token}" ]]; then

View File

@@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
@@ -97,6 +97,17 @@ function writePublishablePluginPackage(repoDir: string): string {
return packageDir;
}
function writeLocalDependencyPackage(packageDir: string): void {
const dependencyDir = join(packageDir, "deps", "local-runtime-dep");
mkdirSync(dependencyDir, { recursive: true });
writeJsonFile(join(dependencyDir, "package.json"), {
name: "local-runtime-dep",
version: "1.0.0",
main: "index.js",
});
writeFileText(join(dependencyDir, "index.js"), "module.exports = 1;\n");
}
describe("plugin npm package manifest staging", () => {
it("overlays generated channel configs while packing and restores source manifest", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-manifest-");
@@ -172,12 +183,14 @@ describe("plugin npm package manifest staging", () => {
const resolved = resolveAugmentedPluginNpmPackageJson({
repoRoot: repoDir,
packageDir,
bundleDependencies: true,
});
expect(resolved.changed).toBe(true);
expect(resolved.packageJson).toEqual({
name: "@openclaw/diffs",
version: "2026.5.3",
type: "module",
bundleDependencies: true,
files: [
"dist/**",
"openclaw.plugin.json",
@@ -209,17 +222,94 @@ describe("plugin npm package manifest staging", () => {
});
const originalText = readFileSync(join(packageDir, "package.json"), "utf8");
withAugmentedPluginNpmManifestForPackage({ repoRoot: repoDir, packageDir }, () => {
const stagedPackageJson = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
expect(stagedPackageJson.openclaw.extensions).toEqual(["./index.ts"]);
expect(stagedPackageJson.openclaw.runtimeExtensions).toEqual(["./dist/index.js"]);
expect(stagedPackageJson.openclaw.runtimeSetupEntry).toBe("./dist/setup-entry.js");
expect(stagedPackageJson.files).toContain("dist/**");
expect(stagedPackageJson.files).toContain("npm-shrinkwrap.json");
expect(stagedPackageJson.files).toContain("skills/**");
expect(stagedPackageJson.peerDependencies.openclaw).toBe(">=2026.4.30");
expect(stagedPackageJson.peerDependenciesMeta.openclaw.optional).toBe(true);
withAugmentedPluginNpmManifestForPackage(
{ repoRoot: repoDir, packageDir, bundleDependencies: true },
() => {
const stagedPackageJson = JSON.parse(
readFileSync(join(packageDir, "package.json"), "utf8"),
);
expect(stagedPackageJson.openclaw.extensions).toEqual(["./index.ts"]);
expect(stagedPackageJson.openclaw.runtimeExtensions).toEqual(["./dist/index.js"]);
expect(stagedPackageJson.openclaw.runtimeSetupEntry).toBe("./dist/setup-entry.js");
expect(stagedPackageJson.bundleDependencies).toBe(true);
expect(stagedPackageJson.files).toContain("dist/**");
expect(stagedPackageJson.files).toContain("npm-shrinkwrap.json");
expect(stagedPackageJson.files).toContain("skills/**");
expect(stagedPackageJson.peerDependencies.openclaw).toBe(">=2026.4.30");
expect(stagedPackageJson.peerDependenciesMeta.openclaw.optional).toBe(true);
},
);
expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe(originalText);
});
it("installs and cleans package-local bundled dependencies while packing", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-bundled-deps-");
const packageDir = writePublishablePluginPackage(repoDir);
writeFileText(join(packageDir, "dist", "index.js"), "export {};\n");
writeFileText(join(packageDir, "dist", "setup-entry.js"), "export {};\n");
writeLocalDependencyPackage(packageDir);
writeJsonFile(join(packageDir, "package.json"), {
name: "@openclaw/diffs",
version: "2026.5.3",
type: "module",
dependencies: {
"local-runtime-dep": "file:./deps/local-runtime-dep",
},
devDependencies: {
"@openclaw/plugin-sdk": "workspace:*",
},
openclaw: {
extensions: ["./index.ts"],
setupEntry: "./setup-entry.ts",
compat: {
pluginApi: ">=2026.4.30",
},
release: {
publishToNpm: true,
},
},
});
writeJsonFile(join(packageDir, "npm-shrinkwrap.json"), {
name: "@openclaw/diffs",
version: "2026.5.3",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name: "@openclaw/diffs",
version: "2026.5.3",
dependencies: {
"local-runtime-dep": "file:./deps/local-runtime-dep",
},
},
"deps/local-runtime-dep": {
name: "local-runtime-dep",
version: "1.0.0",
},
"node_modules/local-runtime-dep": {
resolved: "deps/local-runtime-dep",
link: true,
},
},
});
const originalText = readFileSync(join(packageDir, "package.json"), "utf8");
const nodeModulesPath = join(packageDir, "node_modules");
expect(existsSync(nodeModulesPath)).toBe(false);
withAugmentedPluginNpmManifestForPackage(
{ repoRoot: repoDir, packageDir, bundleDependencies: true },
() => {
const stagedPackageJson = JSON.parse(
readFileSync(join(packageDir, "package.json"), "utf8"),
);
expect(stagedPackageJson.bundleDependencies).toBe(true);
expect(stagedPackageJson.devDependencies).toBeUndefined();
expect(existsSync(join(nodeModulesPath, "local-runtime-dep", "package.json"))).toBe(true);
},
);
expect(existsSync(nodeModulesPath)).toBe(false);
expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe(originalText);
});