mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:00:42 +00: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:
@@ -1,18 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectPreparedPrepackErrors, shouldSkipPrepack } from "../scripts/openclaw-prepack.ts";
|
||||
|
||||
describe("shouldSkipPrepack", () => {
|
||||
it("treats unset and explicit false values as disabled", () => {
|
||||
expect(shouldSkipPrepack({})).toBe(false);
|
||||
expect(shouldSkipPrepack({ OPENCLAW_PREPACK_PREPARED: "0" })).toBe(false);
|
||||
expect(shouldSkipPrepack({ OPENCLAW_PREPACK_PREPARED: "false" })).toBe(false);
|
||||
});
|
||||
|
||||
it("treats non-false values as enabled", () => {
|
||||
expect(shouldSkipPrepack({ OPENCLAW_PREPACK_PREPARED: "1" })).toBe(true);
|
||||
expect(shouldSkipPrepack({ OPENCLAW_PREPACK_PREPARED: "true" })).toBe(true);
|
||||
});
|
||||
});
|
||||
import { collectPreparedPrepackErrors } from "../scripts/openclaw-prepack.ts";
|
||||
|
||||
describe("collectPreparedPrepackErrors", () => {
|
||||
it("accepts prepared release artifacts", () => {
|
||||
|
||||
@@ -122,6 +122,14 @@ describe("bundled plugin root runtime mirrors", () => {
|
||||
function makeBundledSpecs() {
|
||||
return new Map([
|
||||
["@larksuiteoapi/node-sdk", { conflicts: [], pluginIds: ["feishu"], spec: "^1.60.0" }],
|
||||
[
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs",
|
||||
{ conflicts: [], pluginIds: ["matrix"], spec: "^0.4.0" },
|
||||
],
|
||||
[
|
||||
"@matrix-org/matrix-sdk-crypto-wasm",
|
||||
{ conflicts: [], pluginIds: ["matrix"], spec: "18.0.0" },
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -156,8 +164,18 @@ describe("bundled plugin root runtime mirrors", () => {
|
||||
distDir,
|
||||
});
|
||||
|
||||
expect([...mirrors.keys()]).toEqual(["@larksuiteoapi/node-sdk"]);
|
||||
expect([...mirrors.keys()].toSorted((left, right) => left.localeCompare(right))).toEqual([
|
||||
"@larksuiteoapi/node-sdk",
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm",
|
||||
]);
|
||||
expect([...mirrors.get("@larksuiteoapi/node-sdk")!.importers]).toEqual(["probe-Cz2PiFtC.js"]);
|
||||
expect([...mirrors.get("@matrix-org/matrix-sdk-crypto-nodejs")!.importers]).toEqual([
|
||||
"<curated root runtime surface>",
|
||||
]);
|
||||
expect([...mirrors.get("@matrix-org/matrix-sdk-crypto-wasm")!.importers]).toEqual([
|
||||
"<curated root runtime surface>",
|
||||
]);
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -247,7 +265,7 @@ describe("bundled plugin root runtime mirrors", () => {
|
||||
});
|
||||
|
||||
describe("collectForbiddenPackPaths", () => {
|
||||
it("allows bundled plugin runtime deps under dist/extensions but still blocks other node_modules", () => {
|
||||
it("blocks all packaged node_modules payloads", () => {
|
||||
expect(
|
||||
collectForbiddenPackPaths([
|
||||
"dist/index.js",
|
||||
@@ -255,7 +273,11 @@ describe("collectForbiddenPackPaths", () => {
|
||||
bundledPluginFile("tlon", "node_modules/.bin/tlon"),
|
||||
"node_modules/.bin/openclaw",
|
||||
]),
|
||||
).toEqual([bundledPluginFile("tlon", "node_modules/.bin/tlon"), "node_modules/.bin/openclaw"]);
|
||||
).toEqual([
|
||||
bundledDistPluginFile("discord", "node_modules/@buape/carbon/index.js"),
|
||||
bundledPluginFile("tlon", "node_modules/.bin/tlon"),
|
||||
"node_modules/.bin/openclaw",
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks generated docs artifacts from npm pack output", () => {
|
||||
@@ -296,6 +318,7 @@ describe("collectMissingPackPaths", () => {
|
||||
"dist/control-ui/index.html",
|
||||
"qa/scenarios/index.md",
|
||||
"scripts/npm-runner.mjs",
|
||||
"scripts/preinstall-package-manager-warning.mjs",
|
||||
"scripts/postinstall-bundled-plugins.mjs",
|
||||
bundledDistPluginFile("diffs", "assets/viewer-runtime.js"),
|
||||
bundledDistPluginFile("matrix", "helper-api.js"),
|
||||
@@ -327,6 +350,7 @@ describe("collectMissingPackPaths", () => {
|
||||
...requiredPluginSdkPackPaths,
|
||||
...WORKSPACE_TEMPLATE_PACK_PATHS,
|
||||
"scripts/npm-runner.mjs",
|
||||
"scripts/preinstall-package-manager-warning.mjs",
|
||||
"scripts/postinstall-bundled-plugins.mjs",
|
||||
"dist/plugin-sdk/root-alias.cjs",
|
||||
"dist/build-info.json",
|
||||
|
||||
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