diff --git a/CHANGELOG.md b/CHANGELOG.md index 2787c2b49142..115f4239dcdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 871ad68a73ea..790849992fa8 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -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@ --json --pack-destination /tmp/openclaw-plugin-pack tar -tf /tmp/openclaw-plugin-pack/openclaw-discord-.tgz | grep '^package/npm-shrinkwrap.json$' +tar -tf /tmp/openclaw-plugin-pack/openclaw-discord-.tgz | grep '^package/node_modules/' ``` Background: [npm-shrinkwrap.json](https://docs.npmjs.com/cli/v11/configuring-npm/npm-shrinkwrap-json). diff --git a/docs/plugins/dependency-resolution.md b/docs/plugins/dependency-resolution.md index 3592b66f4b72..6b1eacfe173d 100644 --- a/docs/plugins/dependency-resolution.md +++ b/docs/plugins/dependency-resolution.md @@ -74,6 +74,13 @@ policy, and writes `extensions//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 diff --git a/extensions/msteams/npm-shrinkwrap.json b/extensions/msteams/npm-shrinkwrap.json index 181f820d095b..630f9bf1f9b6 100644 --- a/extensions/msteams/npm-shrinkwrap.json +++ b/extensions/msteams/npm-shrinkwrap.json @@ -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" diff --git a/extensions/slack/npm-shrinkwrap.json b/extensions/slack/npm-shrinkwrap.json index f83bf4225389..c4436adfc8f2 100644 --- a/extensions/slack/npm-shrinkwrap.json +++ b/extensions/slack/npm-shrinkwrap.json @@ -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" diff --git a/extensions/whatsapp/npm-shrinkwrap.json b/extensions/whatsapp/npm-shrinkwrap.json index a2cd8e364c0a..84f4174f9c73 100644 --- a/extensions/whatsapp/npm-shrinkwrap.json +++ b/extensions/whatsapp/npm-shrinkwrap.json @@ -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" diff --git a/extensions/zalouser/npm-shrinkwrap.json b/extensions/zalouser/npm-shrinkwrap.json index 42039095cd86..1cf2e35180c9 100644 --- a/extensions/zalouser/npm-shrinkwrap.json +++ b/extensions/zalouser/npm-shrinkwrap.json @@ -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" diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index c40519b3d7fb..51d0464a50fa 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -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": { diff --git a/scripts/lib/plugin-npm-package-manifest.mjs b/scripts/lib/plugin-npm-package-manifest.mjs index df8c7598d6ff..709f0d6e690e 100644 --- a/scripts/lib/plugin-npm-package-manifest.mjs +++ b/scripts/lib/plugin-npm-package-manifest.mjs @@ -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) { diff --git a/scripts/plugin-npm-publish.sh b/scripts/plugin-npm-publish.sh index 9259ad5c082c..1821d69ab4aa 100644 --- a/scripts/plugin-npm-publish.sh +++ b/scripts/plugin-npm-publish.sh @@ -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 diff --git a/test/plugin-npm-package-manifest.test.ts b/test/plugin-npm-package-manifest.test.ts index eb489c00acf7..b7da1ef3e405 100644 --- a/test/plugin-npm-package-manifest.test.ts +++ b/test/plugin-npm-package-manifest.test.ts @@ -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); });