build: verify bundled plugin runtime mirrors

This commit is contained in:
George Zhang
2026-04-22 20:06:51 -07:00
parent d5a06defda
commit 79bbb105a8
5 changed files with 59 additions and 168 deletions

View File

@@ -158,6 +158,7 @@ Docs: https://docs.openclaw.ai
- OpenShell/sandbox: pin verified file reads to an already-opened descriptor, walk the ancestor chain for symlinked parents on platforms without fd-path readlink, and re-check file identity so parent symlink swaps cannot redirect in-sandbox reads to host files outside the allowed mount root. (#69798) Thanks @drobison00.
- Gateway/Control UI: require authenticated Control UI read access before serving `/__openclaw/control-ui-config.json` when `gateway.auth` is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00.
- Gateway/restart: default session-scoped restart sentinels to a one-shot agent continuation, so chat-initiated Gateway restarts acknowledge successful boot automatically. (#70269) Thanks @obviyus.
- Build/npm publish: fail postpublish verification when root `dist/*` files import bundled plugin runtime dependencies without mirroring them in the root package manifest, so Slack-style plugin deps cannot silently ship on the wrong module-resolution path again. (#60112) thanks @medns.
## 2026.4.21

View File

@@ -151,6 +151,10 @@ function extractModuleSpecifiers(source) {
return specifiers;
}
function isPluginOwnedDistImporter(relativePath, pluginIds) {
return pluginIds.some((pluginId) => relativePath.startsWith(`extensions/${pluginId}/`));
}
export function collectRootDistBundledRuntimeMirrors(params) {
const distDir = params.distDir;
const bundledSpecs = params.bundledRuntimeDependencySpecs;
@@ -177,6 +181,9 @@ export function collectRootDistBundledRuntimeMirrors(params) {
continue;
}
const bundledSpec = bundledSpecs.get(dependencyName);
if (isPluginOwnedDistImporter(relativePath, bundledSpec.pluginIds)) {
continue;
}
const existing = mirrors.get(dependencyName);
if (existing) {
existing.importers.add(relativePath);
@@ -195,6 +202,7 @@ export function collectRootDistBundledRuntimeMirrors(params) {
export function collectBundledPluginRootRuntimeMirrorErrors(params) {
const errors = [];
const declaredRootRuntimeDeps = collectRuntimeDependencySpecs(params.rootPackageJson);
for (const [dependencyName, record] of params.bundledRuntimeDependencySpecs) {
for (const conflict of record.conflicts) {
@@ -204,5 +212,17 @@ export function collectBundledPluginRootRuntimeMirrorErrors(params) {
}
}
return errors;
for (const [dependencyName, record] of params.requiredRootMirrors) {
if (declaredRootRuntimeDeps.has(dependencyName)) {
continue;
}
const importerList = Array.from(record.importers)
.toSorted((left, right) => left.localeCompare(right))
.join(", ");
errors.push(
`installed package root is missing mirrored bundled runtime dependency '${dependencyName}' for dist importers: ${importerList}. Add it to package.json dependencies/optionalDependencies or keep imports under dist/extensions/${record.pluginIds[0]}/.`,
);
}
return errors.toSorted((left, right) => left.localeCompare(right));
}

View File

@@ -9,9 +9,6 @@ type TsdownConfigEntry = {
entry?: Record<string, string> | string[];
inputOptions?: TsdownInputOptions;
outDir?: string;
outputOptions?: (options: unknown) => {
chunkFileNames?: (chunkInfo: { name: string; moduleIds: string[] }) => string;
};
};
type TsdownLog = {
@@ -148,117 +145,4 @@ describe("tsdown config", () => {
expect(handled).toEqual([log]);
});
it("routes bundled plugin shared chunks to their own directory", () => {
const configs = asConfigArray(tsdownConfig);
const unifiedGraph = configs.find((config) => entryKeys(config).includes("index"));
expect(unifiedGraph).toBeDefined();
// Extract the chunkFileNames function from outputOptions
const outputOptionsFn = unifiedGraph!.outputOptions;
expect(typeof outputOptionsFn).toBe("function");
const outputOptions = outputOptionsFn!({});
const chunkFileNames = outputOptions.chunkFileNames!;
expect(typeof chunkFileNames).toBe("function");
// Scenario 1: A chunk containing only slack files
expect(
chunkFileNames({
name: "shared-slack-api",
moduleIds: [
"extensions/slack/src/api.ts",
"extensions/slack/src/token.ts",
],
}),
).toBe("extensions/slack/[name]-[hash].js");
// Scenario 2: A chunk containing only telegram files
expect(
chunkFileNames({
name: "shared-telegram-api",
moduleIds: [
"extensions/telegram/src/api.ts",
"extensions/telegram/src/config.ts",
],
}),
).toBe("extensions/telegram/[name]-[hash].js");
// Scenario 3: A chunk containing mixed files (architectural violation)
expect(
chunkFileNames({
name: "shared-mixed",
moduleIds: [
"extensions/slack/src/api.ts",
"extensions/telegram/src/api.ts",
],
}),
).toBe("[name]-[hash].js");
// Scenario 4: A chunk containing only core files
expect(
chunkFileNames({
name: "shared-core",
moduleIds: [
"src/gateway/server-http.ts",
"src/gateway/client.ts",
],
}),
).toBe("[name]-[hash].js");
// Scenario 5: A chunk containing plugin and core files
expect(
chunkFileNames({
name: "shared-plugin-and-core",
moduleIds: [
"extensions/slack/src/api.ts",
"src/gateway/server-http.ts",
],
}),
).toBe("[name]-[hash].js");
// Scenario 5b: A chunk containing plugin files and virtual modules
expect(
chunkFileNames({
name: "shared-plugin-with-virtual",
moduleIds: [
"extensions/slack/src/api.ts",
"\0commonjsHelpers.js",
],
}),
).toBe("extensions/slack/[name]-[hash].js");
// Scenario 5c: A chunk containing plugin files and node_modules dependencies
expect(
chunkFileNames({
name: "shared-plugin-with-deps",
moduleIds: [
"extensions/slack/src/api.ts",
"node_modules/@slack/web-api/index.js",
],
}),
).toBe("extensions/slack/[name]-[hash].js");
// Scenario 6: Fallback to previous function
const outputOptionsWithFn = outputOptionsFn!({
chunkFileNames: () => "custom-fn-[hash].js",
});
expect(
outputOptionsWithFn.chunkFileNames!({
name: "shared-core",
moduleIds: ["src/gateway/server-http.ts"],
}),
).toBe("custom-fn-[hash].js");
// Scenario 7: Fallback to previous string
const outputOptionsWithStr = outputOptionsFn!({
chunkFileNames: "custom-str-[hash].js",
});
expect(
outputOptionsWithStr.chunkFileNames!({
name: "shared-core",
moduleIds: ["src/gateway/server-http.ts"],
}),
).toBe("custom-str-[hash].js");
});
});

View File

@@ -166,28 +166,49 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => {
writeFileSync(fullPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function writeSlackWebApiProbePackage(
root: string,
dependencies: Record<string, string> = {},
): void {
writePackageFile(root, "package.json", {
function writeSlackWebApiProbePackage(params: {
root: string;
importerPath?: string;
rootDependencies?: Record<string, string>;
rootOptionalDependencies?: Record<string, string>;
}): void {
writePackageFile(params.root, "package.json", {
version: "2026.4.10",
dependencies,
dependencies: params.rootDependencies,
optionalDependencies: params.rootOptionalDependencies,
});
writePackageFile(root, "dist/extensions/slack/package.json", {
writePackageFile(params.root, "dist/extensions/slack/package.json", {
dependencies: {
"@slack/web-api": "^7.15.0",
},
});
mkdirSync(join(root, "dist"), { recursive: true });
writeFileSync(join(root, "dist", "probe-Cz2PiFtC.js"), 'import("@slack/web-api");\n', "utf8");
const importerPath = params.importerPath ?? "dist/probe-Cz2PiFtC.js";
mkdirSync(join(params.root, "dist"), { recursive: true });
writeFileSync(join(params.root, importerPath), 'import("@slack/web-api");\n', "utf8");
}
it("does not require root mirrors for bundled plugin deps imported by root dist", () => {
it("flags bundled plugin deps imported by root dist when root mirrors are missing", () => {
const packageRoot = makeInstalledPackageRoot();
try {
writeSlackWebApiProbePackage(packageRoot);
writeSlackWebApiProbePackage({ root: packageRoot });
expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([
"installed package root is missing mirrored bundled runtime dependency '@slack/web-api' for dist importers: probe-Cz2PiFtC.js. Add it to package.json dependencies/optionalDependencies or keep imports under dist/extensions/slack/.",
]);
} finally {
rmSync(packageRoot, { recursive: true, force: true });
}
});
it("allows bundled plugin deps imported from their own extension dist without root mirrors", () => {
const packageRoot = makeInstalledPackageRoot();
try {
writeSlackWebApiProbePackage({
root: packageRoot,
importerPath: "dist/extensions/slack/client-Cz2PiFtC.js",
});
expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]);
} finally {
@@ -227,8 +248,11 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => {
const packageRoot = makeInstalledPackageRoot();
try {
writeSlackWebApiProbePackage(packageRoot, {
"@slack/web-api": "^7.16.0",
writeSlackWebApiProbePackage({
root: packageRoot,
rootDependencies: {
"@slack/web-api": "^7.16.0",
},
});
expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]);

View File

@@ -88,44 +88,6 @@ function nodeBuildConfig(config: UserConfig): UserConfig {
fixedExtension: false,
platform: "node",
inputOptions: buildInputOptions,
outputOptions(options) {
const previousChunkFileNames = options.chunkFileNames;
return {
...options,
chunkFileNames(chunkInfo) {
const moduleIds = chunkInfo.moduleIds || [];
const extensionIds = new Set<string>();
let hasNonPluginModules = false;
for (const id of moduleIds) {
if (id.startsWith("\0")) {
continue;
}
const absoluteId = path.resolve(process.cwd(), id);
const relativeToRoot = path.relative(process.cwd(), absoluteId);
const parts = relativeToRoot.split(path.sep);
if (parts[0] === "extensions" && parts.length > 2) {
extensionIds.add(parts[1]);
} else if (parts.includes("node_modules")) {
continue;
} else {
hasNonPluginModules = true;
}
}
if (extensionIds.size === 1 && !hasNonPluginModules) {
const extId = Array.from(extensionIds)[0];
return `extensions/${extId}/[name]-[hash].js`;
}
if (typeof previousChunkFileNames === "function") {
return previousChunkFileNames(chunkInfo);
}
if (typeof previousChunkFileNames === "string") {
return previousChunkFileNames;
}
return `[name]-[hash].js`;
},
};
},
};
}