mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
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:
72
test/scripts/preinstall-package-manager-warning.test.ts
Normal file
72
test/scripts/preinstall-package-manager-warning.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
82
test/scripts/root-dependency-ownership-audit.test.ts
Normal file
82
test/scripts/root-dependency-ownership-audit.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user