Files
openclaw/src/plugins/sdk-alias.ts
xieyongliang 7cc86e9685 fix(release): add plugin-sdk:check-exports to release:check (#54283)
* fix(plugins): resolve sdk alias from import.meta.url for external plugins

When a plugin is installed outside the openclaw package (e.g.
~/.openclaw/extensions/), resolveLoaderPluginSdkPackageRoot() fails to
locate the openclaw root via cwd or argv1 hints, resulting in an empty
alias map. Jiti then cannot resolve openclaw/plugin-sdk/* imports and
the plugin fails to load with "Cannot find module".

Since sdk-alias.ts is always compiled into the openclaw package itself,
import.meta.url reliably points inside the installation directory. Add it
as an unconditional fallback in resolveLoaderPluginSdkPackageRoot() so
external plugins can always resolve the plugin SDK.

Fixes: Error: Cannot find module 'openclaw/plugin-sdk/plugin-entry'

* fix(plugins): pass loader moduleUrl to resolve sdk alias for external plugins

The previous approach of adding import.meta.url as an unconditional
fallback inside resolveLoaderPluginSdkPackageRoot() broke test isolation:
tests that expected null from untrusted fixtures started finding the real
openclaw root. Revert that and instead thread an optional moduleUrl through
buildPluginLoaderAliasMap → resolvePluginSdkScopedAliasMap →
listPluginSdkExportedSubpaths → resolveLoaderPluginSdkPackageRoot.

loader.ts passes its own import.meta.url as the hint, which is always
inside the openclaw installation. This guarantees the sdk alias map is
built correctly even when argv1 does not resolve to the openclaw root
(e.g. single-binary distributions, custom launchers, or Docker images
where the binary wrapper is not a standard npm symlink).

Tests that call sdk-alias helpers directly without moduleUrl are
unaffected and continue to enforce the existing isolation semantics.
A new test covers the moduleUrl resolution path explicitly.

* fix(plugins): use existing fixture file for moduleUrl hint in test

The previous test pointed loaderModuleUrl to dist/plugins/loader.js
which is not created by createPluginSdkAliasFixture, causing resolution
to fall back to the real openclaw root instead of the fixture root.
Use fixture.root/openclaw.mjs (created by the bin+marker fixture) so
the moduleUrl hint reliably resolves to the fixture package root.

* fix(test): use fixture.root as cwd in external plugin alias test

When process.cwd() is mocked to the external plugin dir, the
findNearestPluginSdkPackageRoot(process.cwd()) fallback resolves to
the real openclaw repo root in the CI test runner, making the test
resolve the wrong aliases. Using fixture.root as cwd ensures all
resolution paths consistently point to the fixture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(release): add plugin-sdk:check-exports to release:check

plugin-sdk subpath exports (e.g. openclaw/plugin-sdk/plugin-entry,
openclaw/plugin-sdk/provider-auth) were missing from the published
package.json, causing external plugins to fail at load time with
'Cannot find module openclaw/plugin-sdk/plugin-entry'.

Root cause: sync-plugin-sdk-exports.mjs syncs plugin-sdk-entrypoints.json
into package.json exports, but this sync was never validated in the
release:check pipeline. As a result, any drift between
plugin-sdk-entrypoints.json and the published package.json goes
undetected until users hit the runtime error.

Fix: add plugin-sdk:check-exports to release:check so the CI gate
fails loudly if the exports are out of sync before publishing.

* fix(test): isolate moduleUrl hint test from process.cwd() fallback

Use externalPluginRoot as cwd instead of fixture.root, so only the
moduleUrl hint can resolve the openclaw package root. Previously,
withCwd(fixture.root) allowed the process.cwd() fallback to also
resolve the fixture root, making the moduleUrl path untested.

Spotted by greptile-apps review on #54283.

* fix(test): use empty string to disable argv1 in moduleUrl hint test

Passing undefined for argv1 in buildPluginLoaderAliasMap triggers the
STARTUP_ARGV1 default (process.argv[1], the vitest runner binary inside
the openclaw repo). resolveTrustedOpenClawRootFromArgvHint then resolves
to the real openclaw root before the moduleUrl hint is checked, making
the test resolve wrong aliases.

Pass "" instead: falsy so the hint is skipped, but does not trigger the
default parameter value. Only the moduleUrl can bridge the gap.

Made-with: Cursor

* fix(plugins): thread moduleUrl through SDK alias resolution for external plugins (#54283) Thanks @xieyongliang

---------

Co-authored-by: bojsun <bojie.sun@bytedance.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jerry <jerry@JerrydeMacBook-Air-2.local>
Co-authored-by: yongliang.xie <yongliang.xie@bytedance.com>
Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-25 09:11:17 -07:00

406 lines
12 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
type PluginSdkAliasCandidateKind = "dist" | "src";
export type LoaderModuleResolveParams = {
modulePath?: string;
argv1?: string;
cwd?: string;
moduleUrl?: string;
};
type PluginSdkPackageJson = {
exports?: Record<string, unknown>;
bin?: string | Record<string, unknown>;
};
const STARTUP_ARGV1 = process.argv[1];
function resolveLoaderModulePath(params: LoaderModuleResolveParams = {}): string {
return params.modulePath ?? fileURLToPath(params.moduleUrl ?? import.meta.url);
}
function readPluginSdkPackageJson(packageRoot: string): PluginSdkPackageJson | null {
try {
const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
return JSON.parse(pkgRaw) as PluginSdkPackageJson;
} catch {
return null;
}
}
function isSafePluginSdkSubpathSegment(subpath: string): boolean {
return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(subpath);
}
function listPluginSdkSubpathsFromPackageJson(pkg: PluginSdkPackageJson): string[] {
return Object.keys(pkg.exports ?? {})
.filter((key) => key.startsWith("./plugin-sdk/"))
.map((key) => key.slice("./plugin-sdk/".length))
.filter((subpath) => isSafePluginSdkSubpathSegment(subpath))
.toSorted();
}
function hasTrustedOpenClawRootIndicator(params: {
packageRoot: string;
packageJson: PluginSdkPackageJson;
}): boolean {
const packageExports = params.packageJson.exports ?? {};
const hasPluginSdkRootExport = Object.prototype.hasOwnProperty.call(
packageExports,
"./plugin-sdk",
);
if (!hasPluginSdkRootExport) {
return false;
}
const hasCliEntryExport = Object.prototype.hasOwnProperty.call(packageExports, "./cli-entry");
const hasOpenClawBin =
(typeof params.packageJson.bin === "string" &&
params.packageJson.bin.toLowerCase().includes("openclaw")) ||
(typeof params.packageJson.bin === "object" &&
params.packageJson.bin !== null &&
typeof params.packageJson.bin.openclaw === "string");
const hasOpenClawEntrypoint = fs.existsSync(path.join(params.packageRoot, "openclaw.mjs"));
return hasCliEntryExport || hasOpenClawBin || hasOpenClawEntrypoint;
}
function readPluginSdkSubpathsFromPackageRoot(packageRoot: string): string[] | null {
const pkg = readPluginSdkPackageJson(packageRoot);
if (!pkg) {
return null;
}
if (!hasTrustedOpenClawRootIndicator({ packageRoot, packageJson: pkg })) {
return null;
}
const subpaths = listPluginSdkSubpathsFromPackageJson(pkg);
return subpaths.length > 0 ? subpaths : null;
}
function resolveTrustedOpenClawRootFromArgvHint(params: {
argv1?: string;
cwd: string;
}): string | null {
if (!params.argv1) {
return null;
}
const packageRoot = resolveOpenClawPackageRootSync({
cwd: params.cwd,
argv1: params.argv1,
});
if (!packageRoot) {
return null;
}
const packageJson = readPluginSdkPackageJson(packageRoot);
if (!packageJson) {
return null;
}
return hasTrustedOpenClawRootIndicator({ packageRoot, packageJson }) ? packageRoot : null;
}
function findNearestPluginSdkPackageRoot(startDir: string, maxDepth = 12): string | null {
let cursor = path.resolve(startDir);
for (let i = 0; i < maxDepth; i += 1) {
const subpaths = readPluginSdkSubpathsFromPackageRoot(cursor);
if (subpaths) {
return cursor;
}
const parent = path.dirname(cursor);
if (parent === cursor) {
break;
}
cursor = parent;
}
return null;
}
export function resolveLoaderPackageRoot(
params: LoaderModuleResolveParams & { modulePath: string },
): string | null {
const cwd = params.cwd ?? path.dirname(params.modulePath);
const fromModulePath = resolveOpenClawPackageRootSync({ cwd });
if (fromModulePath) {
return fromModulePath;
}
const argv1 = params.argv1 ?? process.argv[1];
const moduleUrl = params.moduleUrl ?? (params.modulePath ? undefined : import.meta.url);
return resolveOpenClawPackageRootSync({
cwd,
...(argv1 ? { argv1 } : {}),
...(moduleUrl ? { moduleUrl } : {}),
});
}
function resolveLoaderPluginSdkPackageRoot(
params: LoaderModuleResolveParams & { modulePath: string },
): string | null {
const cwd = params.cwd ?? path.dirname(params.modulePath);
const fromCwd = resolveOpenClawPackageRootSync({ cwd });
const fromExplicitHints =
resolveTrustedOpenClawRootFromArgvHint({ cwd, argv1: params.argv1 }) ??
(params.moduleUrl
? resolveOpenClawPackageRootSync({
cwd,
moduleUrl: params.moduleUrl,
})
: null);
return (
fromCwd ??
fromExplicitHints ??
findNearestPluginSdkPackageRoot(path.dirname(params.modulePath)) ??
(params.cwd ? findNearestPluginSdkPackageRoot(params.cwd) : null) ??
findNearestPluginSdkPackageRoot(process.cwd())
);
}
export function resolvePluginSdkAliasCandidateOrder(params: {
modulePath: string;
isProduction: boolean;
}): PluginSdkAliasCandidateKind[] {
const normalizedModulePath = params.modulePath.replace(/\\/g, "/");
const isDistRuntime = normalizedModulePath.includes("/dist/");
return isDistRuntime || params.isProduction ? ["dist", "src"] : ["src", "dist"];
}
export function listPluginSdkAliasCandidates(params: {
srcFile: string;
distFile: string;
modulePath: string;
argv1?: string;
cwd?: string;
moduleUrl?: string;
}) {
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
modulePath: params.modulePath,
isProduction: process.env.NODE_ENV === "production",
});
const packageRoot = resolveLoaderPluginSdkPackageRoot(params);
if (packageRoot) {
const candidateMap = {
src: path.join(packageRoot, "src", "plugin-sdk", params.srcFile),
dist: path.join(packageRoot, "dist", "plugin-sdk", params.distFile),
} as const;
return orderedKinds.map((kind) => candidateMap[kind]);
}
let cursor = path.dirname(params.modulePath);
const candidates: string[] = [];
for (let i = 0; i < 6; i += 1) {
const candidateMap = {
src: path.join(cursor, "src", "plugin-sdk", params.srcFile),
dist: path.join(cursor, "dist", "plugin-sdk", params.distFile),
} as const;
for (const kind of orderedKinds) {
candidates.push(candidateMap[kind]);
}
const parent = path.dirname(cursor);
if (parent === cursor) {
break;
}
cursor = parent;
}
return candidates;
}
export function resolvePluginSdkAliasFile(params: {
srcFile: string;
distFile: string;
modulePath?: string;
argv1?: string;
cwd?: string;
moduleUrl?: string;
}): string | null {
try {
const modulePath = resolveLoaderModulePath(params);
for (const candidate of listPluginSdkAliasCandidates({
srcFile: params.srcFile,
distFile: params.distFile,
modulePath,
argv1: params.argv1,
cwd: params.cwd,
moduleUrl: params.moduleUrl,
})) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
} catch {
// ignore
}
return null;
}
const cachedPluginSdkExportedSubpaths = new Map<string, string[]>();
const cachedPluginSdkScopedAliasMaps = new Map<string, Record<string, string>>();
export function listPluginSdkExportedSubpaths(
params: { modulePath?: string; argv1?: string; moduleUrl?: string } = {},
): string[] {
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
const packageRoot = resolveLoaderPluginSdkPackageRoot({
modulePath,
argv1: params.argv1,
moduleUrl: params.moduleUrl,
});
if (!packageRoot) {
return [];
}
const cached = cachedPluginSdkExportedSubpaths.get(packageRoot);
if (cached) {
return cached;
}
const subpaths = readPluginSdkSubpathsFromPackageRoot(packageRoot) ?? [];
cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths);
return subpaths;
}
export function resolvePluginSdkScopedAliasMap(
params: { modulePath?: string; argv1?: string; moduleUrl?: string } = {},
): Record<string, string> {
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
const packageRoot = resolveLoaderPluginSdkPackageRoot({
modulePath,
argv1: params.argv1,
moduleUrl: params.moduleUrl,
});
if (!packageRoot) {
return {};
}
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
modulePath,
isProduction: process.env.NODE_ENV === "production",
});
const cacheKey = `${packageRoot}::${orderedKinds.join(",")}`;
const cached = cachedPluginSdkScopedAliasMaps.get(cacheKey);
if (cached) {
return cached;
}
const aliasMap: Record<string, string> = {};
for (const subpath of listPluginSdkExportedSubpaths({
modulePath,
argv1: params.argv1,
moduleUrl: params.moduleUrl,
})) {
const candidateMap = {
src: path.join(packageRoot, "src", "plugin-sdk", `${subpath}.ts`),
dist: path.join(packageRoot, "dist", "plugin-sdk", `${subpath}.js`),
} as const;
for (const kind of orderedKinds) {
const candidate = candidateMap[kind];
if (fs.existsSync(candidate)) {
aliasMap[`openclaw/plugin-sdk/${subpath}`] = candidate;
break;
}
}
}
cachedPluginSdkScopedAliasMaps.set(cacheKey, aliasMap);
return aliasMap;
}
export function resolveExtensionApiAlias(params: LoaderModuleResolveParams = {}): string | null {
try {
const modulePath = resolveLoaderModulePath(params);
const packageRoot = resolveLoaderPackageRoot({ ...params, modulePath });
if (!packageRoot) {
return null;
}
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
modulePath,
isProduction: process.env.NODE_ENV === "production",
});
const candidateMap = {
src: path.join(packageRoot, "src", "extensionAPI.ts"),
dist: path.join(packageRoot, "dist", "extensionAPI.js"),
} as const;
for (const kind of orderedKinds) {
const candidate = candidateMap[kind];
if (fs.existsSync(candidate)) {
return candidate;
}
}
} catch {
// ignore
}
return null;
}
export function buildPluginLoaderAliasMap(
modulePath: string,
argv1: string | undefined = STARTUP_ARGV1,
moduleUrl?: string,
): Record<string, string> {
const pluginSdkAlias = resolvePluginSdkAliasFile({
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
modulePath,
argv1,
moduleUrl,
});
const extensionApiAlias = resolveExtensionApiAlias({ modulePath });
return {
...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}),
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
...resolvePluginSdkScopedAliasMap({ modulePath, argv1, moduleUrl }),
};
}
export function resolvePluginRuntimeModulePath(
params: LoaderModuleResolveParams = {},
): string | null {
try {
const modulePath = resolveLoaderModulePath(params);
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
modulePath,
isProduction: process.env.NODE_ENV === "production",
});
const packageRoot = resolveLoaderPackageRoot({ ...params, modulePath });
const candidates = packageRoot
? orderedKinds.map((kind) =>
kind === "src"
? path.join(packageRoot, "src", "plugins", "runtime", "index.ts")
: path.join(packageRoot, "dist", "plugins", "runtime", "index.js"),
)
: [
path.join(path.dirname(modulePath), "runtime", "index.ts"),
path.join(path.dirname(modulePath), "runtime", "index.js"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
} catch {
// ignore
}
return null;
}
export function buildPluginLoaderJitiOptions(aliasMap: Record<string, string>) {
return {
interopDefault: true,
// Prefer Node's native sync ESM loader for built dist/*.js modules so
// bundled plugins and plugin-sdk subpaths stay on the canonical module graph.
tryNative: true,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
...(Object.keys(aliasMap).length > 0
? {
alias: aliasMap,
}
: {}),
};
}
export function shouldPreferNativeJiti(modulePath: string): boolean {
switch (path.extname(modulePath).toLowerCase()) {
case ".js":
case ".mjs":
case ".cjs":
case ".json":
return true;
default:
return false;
}
}