fix(plugins): localize bundled runtime deps to extensions (#67099)

* fix(plugins): localize bundled runtime deps to extensions

* fix(plugins): move staged runtime deps out of root

* fix(packaging): harden prepack and runtime dep staging

* fix(packaging): preserve optional runtime dep staging

* Update CHANGELOG.md

* fix(packaging): harden runtime staging filesystem writes

* fix(docker): ship preinstall warning in bootstrap layers

* fix(packaging): exclude staged plugin node_modules from npm pack
This commit is contained in:
Vincent Koc
2026-04-15 12:04:31 +01:00
committed by GitHub
parent a780151fd1
commit c727388f93
29 changed files with 1335 additions and 277 deletions

View File

@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import {
createPackageManagerWarningMessage,
detectLifecyclePackageManager,
warnIfNonPnpmLifecycle,
} from "../../scripts/preinstall-package-manager-warning.mjs";
describe("detectLifecyclePackageManager", () => {
it("prefers npm_config_user_agent when present", () => {
expect(
detectLifecyclePackageManager({
npm_config_user_agent: "npm/11.4.1 node/v22.20.0 darwin arm64",
}),
).toBe("npm");
});
it("falls back to npm_execpath when user agent is missing", () => {
expect(
detectLifecyclePackageManager({
npm_execpath: "/Users/test/.cache/node/corepack/v1/pnpm/10.32.1/bin/pnpm.cjs",
}),
).toBe("pnpm");
});
it("ignores untrusted user-agent tokens with control characters", () => {
expect(
detectLifecyclePackageManager({
npm_config_user_agent: "\u001bnpm/11.4.1 node/v22.20.0 darwin arm64",
npm_execpath: "/Users/test/.cache/node/corepack/v1/pnpm/10.32.1/bin/pnpm.cjs",
}),
).toBe("pnpm");
});
});
describe("createPackageManagerWarningMessage", () => {
it("returns null for pnpm", () => {
expect(createPackageManagerWarningMessage("pnpm")).toBeNull();
});
it("warns for npm installs", () => {
expect(createPackageManagerWarningMessage("npm")).toContain("prefer: corepack pnpm install");
});
});
describe("warnIfNonPnpmLifecycle", () => {
it("warns once for npm lifecycle runs", () => {
const warn = vi.fn();
expect(
warnIfNonPnpmLifecycle(
{
npm_config_user_agent: "npm/11.4.1 node/v22.20.0 darwin arm64",
},
warn,
),
).toBe(true);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toContain("detected npm");
});
it("stays quiet for pnpm", () => {
const warn = vi.fn();
expect(
warnIfNonPnpmLifecycle(
{
npm_config_user_agent: "pnpm/10.32.1 npm/? node/v22.20.0 darwin arm64",
},
warn,
),
).toBe(false);
expect(warn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
classifyRootDependencyOwnership,
collectModuleSpecifiers,
} from "../../scripts/root-dependency-ownership-audit.mjs";
describe("collectModuleSpecifiers", () => {
it("captures require.resolve package lookups used by runtime shims and bundled plugins", () => {
expect([
...collectModuleSpecifiers(`
const require = createRequire(import.meta.url);
const runtimeRequire = createRequire(runtimePackagePath);
require.resolve("gaxios");
runtimeRequire.resolve("openshell/package.json");
`),
]).toEqual(["gaxios", "openshell/package.json"]);
});
});
describe("classifyRootDependencyOwnership", () => {
it("treats root-dist bundled runtime mirrors as blocked extension deps", () => {
expect(
classifyRootDependencyOwnership({
sections: ["extensions"],
rootMirrorImporters: ["discovery-DZDwKJdJ.js"],
}),
).toEqual({
category: "extension_only_root_mirror",
recommendation:
"blocked by packaged host graph: remove root mirror only after bundled runtime resolution stops importing it from root dist",
});
});
it("treats scripts and tests as dev-only candidates", () => {
expect(
classifyRootDependencyOwnership({
sections: ["scripts", "test"],
rootMirrorImporters: [],
}),
).toEqual({
category: "script_or_test_only",
recommendation: "consider moving from dependencies to devDependencies",
});
});
it("treats extension-only deps as localizable when no root mirror exists", () => {
expect(
classifyRootDependencyOwnership({
sections: ["extensions", "test"],
rootMirrorImporters: [],
}),
).toEqual({
category: "extension_only_localizable",
recommendation:
"candidate to remove from root package.json and rely on owning extension manifests",
});
});
it("treats src-owned deps as core runtime", () => {
expect(
classifyRootDependencyOwnership({
sections: ["src"],
rootMirrorImporters: [],
}),
).toEqual({
category: "core_runtime",
recommendation: "keep at root",
});
});
it("treats unreferenced deps as removal candidates", () => {
expect(
classifyRootDependencyOwnership({
sections: [],
rootMirrorImporters: [],
}),
).toEqual({
category: "unreferenced",
recommendation: "investigate removal; no direct source imports found in scanned files",
});
});
});

View File

@@ -1,7 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { stageBundledPluginRuntimeDeps } from "../../scripts/stage-bundled-plugin-runtime-deps.mjs";
import {
collectRuntimeDependencyInstallManifest,
collectRuntimeDependencyInstallSpecs,
stageBundledPluginRuntimeDeps,
} from "../../scripts/stage-bundled-plugin-runtime-deps.mjs";
import { createScriptTestHarness } from "./test-helpers.js";
const { createTempDir } = createScriptTestHarness();
@@ -23,6 +27,90 @@ describe("stageBundledPluginRuntimeDeps", () => {
return { pluginDir, repoRoot };
}
it("pins fallback install specs to exact installed versions", () => {
const { repoRoot } = createBundledPluginFixture({
packageJson: {
name: "@openclaw/fixture-plugin",
version: "1.0.0",
dependencies: {
direct: "^1.0.0",
},
optionalDependencies: {
optional: "~2.0.0",
},
},
});
const rootNodeModulesDir = path.join(repoRoot, "node_modules");
fs.mkdirSync(path.join(rootNodeModulesDir, "direct"), { recursive: true });
fs.mkdirSync(path.join(rootNodeModulesDir, "optional"), { recursive: true });
fs.writeFileSync(
path.join(rootNodeModulesDir, "direct", "package.json"),
'{ "name": "direct", "version": "1.2.3" }\n',
"utf8",
);
fs.writeFileSync(
path.join(rootNodeModulesDir, "optional", "package.json"),
'{ "name": "optional", "version": "2.0.4" }\n',
"utf8",
);
expect(
collectRuntimeDependencyInstallSpecs(
{
dependencies: { direct: "^1.0.0" },
optionalDependencies: { optional: "~2.0.0" },
},
{ rootNodeModulesDir },
),
).toEqual({
dependencies: ["direct@1.2.3"],
optionalDependencies: ["optional@2.0.4"],
});
});
it("rejects unsafe runtime dependency specs for fallback installs", () => {
expect(() =>
collectRuntimeDependencyInstallSpecs(
{
dependencies: { direct: "file:/etc/passwd" },
},
{ rootNodeModulesDir: "/tmp/node_modules" },
),
).toThrow(/disallowed runtime dependency spec for direct: file:\/etc\/passwd/u);
});
it("writes required and optional fallback deps into one manifest", () => {
const rootNodeModulesDir = createTempDir("openclaw-runtime-deps-manifest-");
fs.mkdirSync(path.join(rootNodeModulesDir, "direct"), { recursive: true });
fs.mkdirSync(path.join(rootNodeModulesDir, "optional"), { recursive: true });
fs.writeFileSync(
path.join(rootNodeModulesDir, "direct", "package.json"),
'{ "name": "direct", "version": "1.2.3" }\n',
"utf8",
);
fs.writeFileSync(
path.join(rootNodeModulesDir, "optional", "package.json"),
'{ "name": "optional", "version": "2.0.4" }\n',
"utf8",
);
expect(
collectRuntimeDependencyInstallManifest(
{
dependencies: { direct: "^1.0.0" },
optionalDependencies: { optional: "~2.0.0" },
},
{ pluginId: "fixture-plugin", rootNodeModulesDir },
),
).toEqual({
name: "openclaw-runtime-deps-fixture-plugin",
private: true,
version: "0.0.0",
dependencies: { direct: "1.2.3" },
optionalDependencies: { optional: "2.0.4" },
});
});
it("skips restaging when runtime deps stamp matches the sanitized manifest", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {
@@ -194,6 +282,60 @@ describe("stageBundledPluginRuntimeDeps", () => {
).toBe("module.exports = 'second';\n");
});
it("refuses to replace a symlinked plugin node_modules directory", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {
name: "@openclaw/fixture-plugin",
version: "1.0.0",
dependencies: { direct: "1.0.0" },
openclaw: { bundle: { stageRuntimeDependencies: true } },
},
});
const directDir = path.join(repoRoot, "node_modules", "direct");
const outsideDir = path.join(repoRoot, "outside-node-modules");
const nodeModulesDir = path.join(pluginDir, "node_modules");
fs.mkdirSync(directDir, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
fs.writeFileSync(
path.join(directDir, "package.json"),
'{ "name": "direct", "version": "1.0.0" }\n',
"utf8",
);
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8");
fs.symlinkSync(outsideDir, nodeModulesDir);
expect(() => stageBundledPluginRuntimeDeps({ cwd: repoRoot })).toThrow(
/refusing to replace runtime deps via symlinked path/u,
);
});
it("refuses to write a runtime deps stamp through a symlink", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {
name: "@openclaw/fixture-plugin",
version: "1.0.0",
dependencies: { direct: "1.0.0" },
openclaw: { bundle: { stageRuntimeDependencies: true } },
},
});
const directDir = path.join(repoRoot, "node_modules", "direct");
const outsideStamp = path.join(repoRoot, "outside-stamp.json");
const stampPath = path.join(pluginDir, ".openclaw-runtime-deps-stamp.json");
fs.mkdirSync(directDir, { recursive: true });
fs.writeFileSync(
path.join(directDir, "package.json"),
'{ "name": "direct", "version": "1.0.0" }\n',
"utf8",
);
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8");
fs.writeFileSync(outsideStamp, '{"outside":true}\n', "utf8");
fs.symlinkSync(outsideStamp, stampPath);
expect(() => stageBundledPluginRuntimeDeps({ cwd: repoRoot })).toThrow(
/refusing to write runtime deps stamp via symlinked path/u,
);
});
it("stages runtime deps from the root node_modules when already installed", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {