Stabilize plugin loader and Docker extension smoke (#50058)

* Plugins: stabilize Area 6 loader and Docker smoke

* Docker: fail fast on extension npm install errors

* Tests: stabilize loader non-native Jiti boundary CI timeout

* Tests: stabilize plugin loader Jiti source-runtime coverage

* Docker: keep extension deps on lockfile graph

* Tests: cover tsx-cache renamed package cwd fallback

* Tests: stabilize plugin-sdk export subpath assertions

* Plugins: align tsx-cache alias fallback with subpath fallback

* Tests: normalize guardrail path checks for Windows

* Plugins: restrict plugin-sdk cwd fallback to trusted roots

* Tests: exempt outbound-session from extension import guard

* Tests: tighten guardrails and cli-entry trust coverage

* Tests: guard optional loader fixture exports

* Tests: make loader fixture package exports null-safe

* Tests: make loader fixture package exports null-safe

* Tests: make loader fixture package exports null-safe

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
Josh Avant 2026-03-18 23:35:32 -05:00 committed by GitHub
parent 3abffe0967
commit a2a9a553e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 491 additions and 66 deletions

View File

@ -62,24 +62,57 @@ jobs:
run: |
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
# This smoke only validates that the build-arg path preinstalls selected
# extension deps without breaking image build or basic CLI startup. It
# does not exercise runtime loading/registration of diagnostics-otel.
# This smoke validates that the build-arg path preinstalls selected
# extension deps and that matrix plugin discovery stays healthy in the
# final runtime image.
- name: Build extension Dockerfile smoke image
uses: useblacksmith/build-push-action@v2
with:
context: .
file: ./Dockerfile
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel
OPENCLAW_EXTENSIONS=matrix
tags: openclaw-ext-smoke:local
load: true
push: false
provenance: false
- name: Smoke test Dockerfile with extension build arg
- name: Smoke test Dockerfile with matrix extension build arg
run: |
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version'
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
which openclaw &&
openclaw --version &&
node -e "
const Module = require(\"node:module\");
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
requireFromMatrix.resolve(\"@vector-im/matrix-bot-sdk/package.json\");
requireFromMatrix.resolve(\"@matrix-org/matrix-sdk-crypto-nodejs/package.json\");
const { spawnSync } = require(\"node:child_process\");
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
if (run.status !== 0) {
process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\");
process.exit(run.status ?? 1);
}
const parsed = JSON.parse(run.stdout);
const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\");
if (!matrix) {
throw new Error(\"matrix plugin missing from bundled plugin list\");
}
const matrixDiag = (parsed.diagnostics || []).filter(
(diag) =>
typeof diag.source === \"string\" &&
diag.source.includes(\"/extensions/matrix\") &&
typeof diag.message === \"string\" &&
diag.message.includes(\"extension entry escapes package directory\"),
);
if (matrixDiag.length > 0) {
throw new Error(
\"unexpected matrix diagnostics: \" +
matrixDiag.map((diag) => diag.message).join(\"; \"),
);
}
"
'
- name: Build installer smoke image
uses: useblacksmith/build-push-action@v2

View File

@ -134,6 +134,7 @@ Docs: https://docs.openclaw.ai
- Gateway/channels: serialize per-account channel startup so overlapping starts do not boot the same provider twice, preventing MS Teams `EADDRINUSE` crash loops during startup and restart. (#49583) Thanks @sudie-codes.
- Tests/OpenAI Codex auth: align login expectations with the default `gpt-5.4` model so CI coverage stays consistent with the current OpenAI Codex default. (#44367) Thanks @jrrcdev.
- Discord: enforce strict DM component allowlist auth (#49997) Thanks @joshavant.
- Stabilize plugin loader and Docker extension smoke (#50058) Thanks @joshavant.
### Fixes

View File

@ -146,6 +146,10 @@ COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
# In npm-installed Docker images, prefer the copied source extension tree for
# bundled discovery so package metadata that points at source entries stays valid.
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/extensions
# Keep pnpm available in the runtime image for container-local workflows.
# Use a shared Corepack home so the non-root `node` user does not need a
# first-run network fetch when invoking pnpm.

View File

@ -41,11 +41,17 @@ describe("Dockerfile", () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
expect(dockerfile).toContain("FROM build AS runtime-assets");
expect(dockerfile).toContain("CI=true pnpm prune --prod");
expect(dockerfile).not.toContain('npm install --prefix "extensions/$ext" --omit=dev --silent');
expect(dockerfile).toContain(
"COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules",
);
});
it("pins bundled plugin discovery to copied source extensions in runtime images", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
expect(dockerfile).toContain("ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/extensions");
});
it("normalizes plugin and agent paths permissions in image layers", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
expect(dockerfile).toContain("for dir in /app/extensions /app/.agent /app/.agents");

View File

@ -170,6 +170,10 @@ function readSource(path: string): string {
return readFileSync(resolve(ROOT_DIR, "..", path), "utf8");
}
function normalizePath(path: string): string {
return path.replaceAll("\\", "/");
}
function readSetupBarrelImportBlock(path: string): string {
const lines = readSource(path).split("\n");
const targetLineIndex = lines.findIndex((line) =>
@ -186,10 +190,10 @@ function readSetupBarrelImportBlock(path: string): string {
}
function collectExtensionSourceFiles(): string[] {
const extensionsDir = resolve(ROOT_DIR, "..", "extensions");
const sharedExtensionsDir = resolve(extensionsDir, "shared");
const extensionsDir = normalizePath(resolve(ROOT_DIR, "..", "extensions"));
const sharedExtensionsDir = normalizePath(resolve(extensionsDir, "shared"));
const files: string[] = [];
const stack = [extensionsDir];
const stack = [resolve(ROOT_DIR, "..", "extensions")];
while (stack.length > 0) {
const current = stack.pop();
if (!current) {
@ -197,6 +201,7 @@ function collectExtensionSourceFiles(): string[] {
}
for (const entry of readdirSync(current, { withFileTypes: true })) {
const fullPath = resolve(current, entry.name);
const normalizedFullPath = normalizePath(fullPath);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") {
continue;
@ -207,18 +212,18 @@ function collectExtensionSourceFiles(): string[] {
if (!entry.isFile() || !/\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(entry.name)) {
continue;
}
if (entry.name.endsWith(".d.ts") || fullPath.includes(sharedExtensionsDir)) {
if (entry.name.endsWith(".d.ts") || normalizedFullPath.includes(sharedExtensionsDir)) {
continue;
}
if (fullPath.includes(`${resolve(ROOT_DIR, "..", "extensions")}/shared/`)) {
if (normalizedFullPath.includes(`${extensionsDir}/shared/`)) {
continue;
}
if (
fullPath.includes(".test.") ||
fullPath.includes(".test-") ||
fullPath.includes(".fixture.") ||
fullPath.includes(".snap") ||
fullPath.includes("test-support") ||
normalizedFullPath.includes(".test.") ||
normalizedFullPath.includes(".test-") ||
normalizedFullPath.includes(".fixture.") ||
normalizedFullPath.includes(".snap") ||
normalizedFullPath.includes("test-support") ||
entry.name === "api.ts" ||
entry.name === "runtime-api.ts"
) {
@ -232,6 +237,7 @@ function collectExtensionSourceFiles(): string[] {
function collectCoreSourceFiles(): string[] {
const srcDir = resolve(ROOT_DIR, "..", "src");
const normalizedPluginSdkDir = normalizePath(resolve(ROOT_DIR, "plugin-sdk"));
const files: string[] = [];
const stack = [srcDir];
while (stack.length > 0) {
@ -241,6 +247,7 @@ function collectCoreSourceFiles(): string[] {
}
for (const entry of readdirSync(current, { withFileTypes: true })) {
const fullPath = resolve(current, entry.name);
const normalizedFullPath = normalizePath(fullPath);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") {
continue;
@ -255,14 +262,14 @@ function collectCoreSourceFiles(): string[] {
continue;
}
if (
fullPath.includes(".test.") ||
fullPath.includes(".mock-harness.") ||
fullPath.includes(".spec.") ||
fullPath.includes(".fixture.") ||
fullPath.includes(".snap") ||
normalizedFullPath.includes(".test.") ||
normalizedFullPath.includes(".mock-harness.") ||
normalizedFullPath.includes(".spec.") ||
normalizedFullPath.includes(".fixture.") ||
normalizedFullPath.includes(".snap") ||
// src/plugin-sdk is the curated bridge layer; validate its contracts with dedicated
// plugin-sdk guardrails instead of the generic "core should not touch extensions" rule.
fullPath.includes(`${resolve(ROOT_DIR, "plugin-sdk")}/`)
normalizedFullPath.includes(`${normalizedPluginSdkDir}/`)
) {
continue;
}
@ -283,6 +290,7 @@ function collectExtensionFiles(extensionId: string): string[] {
}
for (const entry of readdirSync(current, { withFileTypes: true })) {
const fullPath = resolve(current, entry.name);
const normalizedFullPath = normalizePath(fullPath);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") {
continue;
@ -297,11 +305,11 @@ function collectExtensionFiles(extensionId: string): string[] {
continue;
}
if (
fullPath.includes(".test.") ||
fullPath.includes(".test-") ||
fullPath.includes(".spec.") ||
fullPath.includes(".fixture.") ||
fullPath.includes(".snap") ||
normalizedFullPath.includes(".test.") ||
normalizedFullPath.includes(".test-") ||
normalizedFullPath.includes(".spec.") ||
normalizedFullPath.includes(".fixture.") ||
normalizedFullPath.includes(".snap") ||
entry.name === "runtime-api.ts"
) {
continue;
@ -392,6 +400,16 @@ describe("channel import guardrails", () => {
}
});
it("keeps bundled extension source files off legacy core send-deps src imports", () => {
const legacyCoreSendDepsImport = /["'][^"']*src\/infra\/outbound\/send-deps\.[cm]?[jt]s["']/;
for (const file of collectExtensionSourceFiles()) {
const text = readFileSync(file, "utf8");
expect(text, `${file} should not import src/infra/outbound/send-deps.*`).not.toMatch(
legacyCoreSendDepsImport,
);
}
});
it("keeps core production files off extension private src imports", () => {
for (const file of collectCoreSourceFiles()) {
const text = readFileSync(file, "utf8");

View File

@ -101,6 +101,16 @@ function makeTempDir() {
return dir;
}
function withCwd<T>(cwd: string, run: () => T): T {
const previousCwd = process.cwd();
process.chdir(cwd);
try {
return run();
} finally {
process.chdir(previousCwd);
}
}
function writePlugin(params: {
id: string;
body: string;
@ -299,17 +309,43 @@ function createPluginSdkAliasFixture(params?: {
distFile?: string;
srcBody?: string;
distBody?: string;
packageName?: string;
packageExports?: Record<string, unknown>;
trustedRootIndicators?: boolean;
trustedRootIndicatorMode?: "bin+marker" | "cli-entry-only" | "none";
}) {
const root = makeTempDir();
const srcFile = path.join(root, "src", "plugin-sdk", params?.srcFile ?? "index.ts");
const distFile = path.join(root, "dist", "plugin-sdk", params?.distFile ?? "index.js");
mkdirSafe(path.dirname(srcFile));
mkdirSafe(path.dirname(distFile));
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
"utf-8",
);
const trustedRootIndicatorMode =
params?.trustedRootIndicatorMode ??
(params?.trustedRootIndicators === false ? "none" : "bin+marker");
const packageJson: Record<string, unknown> = {
name: params?.packageName ?? "openclaw",
type: "module",
};
if (trustedRootIndicatorMode === "bin+marker") {
packageJson.bin = {
openclaw: "openclaw.mjs",
};
}
if (params?.packageExports || trustedRootIndicatorMode === "cli-entry-only") {
const trustedExports: Record<string, unknown> =
trustedRootIndicatorMode === "cli-entry-only"
? { "./cli-entry": { default: "./dist/cli-entry.js" } }
: {};
packageJson.exports = {
"./plugin-sdk": { default: "./dist/plugin-sdk/index.js" },
...trustedExports,
...params?.packageExports,
};
}
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(packageJson, null, 2), "utf-8");
if (trustedRootIndicatorMode === "bin+marker") {
fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n", "utf-8");
}
fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8");
fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8");
return { root, srcFile, distFile };
@ -3326,10 +3362,126 @@ module.exports = {
});
it("derives plugin-sdk subpaths from package exports", () => {
const subpaths = __testing.listPluginSdkExportedSubpaths();
expect(subpaths).toContain("telegram");
expect(subpaths).not.toContain("compat");
expect(subpaths).not.toContain("root-alias");
const fixture = createPluginSdkAliasFixture({
packageExports: {
"./plugin-sdk/compat": { default: "./dist/plugin-sdk/compat.js" },
"./plugin-sdk/telegram": { default: "./dist/plugin-sdk/telegram.js" },
"./plugin-sdk/nested/value": { default: "./dist/plugin-sdk/nested/value.js" },
},
});
const subpaths = __testing.listPluginSdkExportedSubpaths({
modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"),
});
expect(subpaths).toEqual(["compat", "telegram"]);
});
it("derives plugin-sdk subpaths from nearest package exports even when package name is renamed", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
"./plugin-sdk/compat": { default: "./dist/plugin-sdk/compat.js" },
},
});
const subpaths = __testing.listPluginSdkExportedSubpaths({
modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"),
});
expect(subpaths).toEqual(["channel-runtime", "compat", "core"]);
});
it("derives plugin-sdk subpaths via cwd fallback when module path is a transpiler cache and package is renamed", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const subpaths = withCwd(fixture.root, () =>
__testing.listPluginSdkExportedSubpaths({
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
}),
);
expect(subpaths).toEqual(["channel-runtime", "core"]);
});
it("resolves plugin-sdk alias files via cwd fallback when module path is a transpiler cache and package is renamed", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageName: "moltbot",
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const resolved = withCwd(fixture.root, () =>
resolvePluginSdkAlias({
root: fixture.root,
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
env: { NODE_ENV: undefined },
}),
);
expect(resolved).not.toBeNull();
expect(fs.realpathSync(resolved ?? "")).toBe(fs.realpathSync(fixture.srcFile));
});
it("does not derive plugin-sdk subpaths from cwd fallback when package root is not an OpenClaw root", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
trustedRootIndicators: false,
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const subpaths = withCwd(fixture.root, () =>
__testing.listPluginSdkExportedSubpaths({
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
}),
);
expect(subpaths).toEqual([]);
});
it("derives plugin-sdk subpaths via cwd fallback when trusted root indicator is cli-entry export", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
trustedRootIndicatorMode: "cli-entry-only",
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const subpaths = withCwd(fixture.root, () =>
__testing.listPluginSdkExportedSubpaths({
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
}),
);
expect(subpaths).toEqual(["channel-runtime", "core"]);
});
it("does not resolve plugin-sdk alias files from cwd fallback when package root is not an OpenClaw root", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageName: "moltbot",
trustedRootIndicators: false,
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const resolved = withCwd(fixture.root, () =>
resolvePluginSdkAlias({
root: fixture.root,
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
env: { NODE_ENV: undefined },
}),
);
expect(resolved).toBeNull();
});
it("configures the plugin loader jiti boundary to prefer native dist modules", () => {
@ -3361,22 +3513,152 @@ module.exports = {
"src",
"channel.runtime.ts",
);
const discordVoiceRuntime = path.join(
process.cwd(),
"extensions",
"discord",
"src",
"voice",
"manager.runtime.ts",
);
await expect(jiti.import(discordChannelRuntime)).resolves.toMatchObject({
discordSetupWizard: expect.any(Object),
});
await expect(jiti.import(discordVoiceRuntime)).resolves.toMatchObject({
DiscordVoiceManager: expect.any(Function),
DiscordVoiceReadyListener: expect.any(Function),
}, 240_000);
it("loads copied imessage runtime sources from git-style paths with plugin-sdk aliases (#49806)", async () => {
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage");
const copiedSourceDir = path.join(copiedExtensionRoot, "src");
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
mkdirSafe(copiedSourceDir);
mkdirSafe(copiedPluginSdkDir);
const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8");
fs.writeFileSync(
path.join(copiedSourceDir, "channel.runtime.ts"),
`import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js";
export const copiedRuntimeMarker = {
resolveOutboundSendDep,
PAIRING_APPROVED_MESSAGE,
};
`,
"utf-8",
);
fs.writeFileSync(
path.join(copiedExtensionRoot, "runtime-api.ts"),
`export const PAIRING_APPROVED_MESSAGE = "paired";
`,
"utf-8",
);
const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts");
fs.writeFileSync(
copiedChannelRuntimeShim,
`export function resolveOutboundSendDep() {
return "shimmed";
}
`,
"utf-8",
);
const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts");
const jitiBaseUrl = pathToFileURL(jitiBaseFile).href;
const withoutAlias = createJiti(jitiBaseUrl, {
...__testing.buildPluginLoaderJitiOptions({}),
tryNative: false,
});
await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(
/plugin-sdk\/channel-runtime/,
);
const withAlias = createJiti(jitiBaseUrl, {
...__testing.buildPluginLoaderJitiOptions({
"openclaw/plugin-sdk/channel-runtime": copiedChannelRuntimeShim,
}),
tryNative: false,
});
await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({
copiedRuntimeMarker: {
PAIRING_APPROVED_MESSAGE: "paired",
resolveOutboundSendDep: expect.any(Function),
},
});
});
it("loads git-style package extension entries through the plugin loader when they import plugin-sdk channel-runtime (#49806)", () => {
useNoBundledPlugins();
const pluginId = "imessage-loader-regression";
const gitExtensionRoot = path.join(
makeTempDir(),
"git-source-checkout",
"extensions",
pluginId,
);
const gitSourceDir = path.join(gitExtensionRoot, "src");
mkdirSafe(gitSourceDir);
fs.writeFileSync(
path.join(gitExtensionRoot, "package.json"),
JSON.stringify(
{
name: `@openclaw/${pluginId}`,
version: "0.0.1",
type: "module",
openclaw: {
extensions: ["./src/index.ts"],
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(gitExtensionRoot, "openclaw.plugin.json"),
JSON.stringify(
{
id: pluginId,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(gitSourceDir, "channel.runtime.ts"),
`import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
export function runtimeProbeType() {
return typeof resolveOutboundSendDep;
}
`,
"utf-8",
);
fs.writeFileSync(
path.join(gitSourceDir, "index.ts"),
`import { runtimeProbeType } from "./channel.runtime.ts";
export default {
id: ${JSON.stringify(pluginId)},
register() {
if (runtimeProbeType() !== "function") {
throw new Error("channel-runtime import did not resolve");
}
},
};
`,
"utf-8",
);
const registry = withEnv({ NODE_ENV: "production", VITEST: undefined }, () =>
loadOpenClawPlugins({
cache: false,
workspaceDir: gitExtensionRoot,
config: {
plugins: {
load: { paths: [gitExtensionRoot] },
allow: [pluginId],
},
},
}),
);
const record = registry.plugins.find((entry) => entry.id === pluginId);
expect(record?.status).toBe("loaded");
});
it("loads source TypeScript plugins that route through local runtime shims", () => {

View File

@ -12,10 +12,83 @@ export type LoaderModuleResolveParams = {
moduleUrl?: string;
};
type PluginSdkPackageJson = {
exports?: Record<string, unknown>;
bin?: string | Record<string, unknown>;
};
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 listPluginSdkSubpathsFromPackageJson(pkg: PluginSdkPackageJson): string[] {
return Object.keys(pkg.exports ?? {})
.filter((key) => key.startsWith("./plugin-sdk/"))
.map((key) => key.slice("./plugin-sdk/".length))
.filter((subpath) => Boolean(subpath) && !subpath.includes("/"))
.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 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 {
@ -33,6 +106,28 @@ export function resolveLoaderPackageRoot(
});
}
function resolveLoaderPluginSdkPackageRoot(
params: LoaderModuleResolveParams & { modulePath: string },
): string | null {
const cwd = params.cwd ?? path.dirname(params.modulePath);
const fromCwd = resolveOpenClawPackageRootSync({ cwd });
const fromExplicitHints =
params.argv1 || params.moduleUrl
? resolveOpenClawPackageRootSync({
cwd,
...(params.argv1 ? { argv1: params.argv1 } : {}),
...(params.moduleUrl ? { 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;
@ -54,7 +149,7 @@ export function listPluginSdkAliasCandidates(params: {
modulePath: params.modulePath,
isProduction: process.env.NODE_ENV === "production",
});
const packageRoot = resolveLoaderPackageRoot(params);
const packageRoot = resolveLoaderPluginSdkPackageRoot(params);
if (packageRoot) {
const candidateMap = {
src: path.join(packageRoot, "src", "plugin-sdk", params.srcFile),
@ -113,9 +208,7 @@ const cachedPluginSdkExportedSubpaths = new Map<string, string[]>();
export function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] {
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
const packageRoot = resolveOpenClawPackageRootSync({
cwd: path.dirname(modulePath),
});
const packageRoot = resolveLoaderPluginSdkPackageRoot({ modulePath });
if (!packageRoot) {
return [];
}
@ -123,21 +216,9 @@ export function listPluginSdkExportedSubpaths(params: { modulePath?: string } =
if (cached) {
return cached;
}
try {
const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
const pkg = JSON.parse(pkgRaw) as {
exports?: Record<string, unknown>;
};
const subpaths = Object.keys(pkg.exports ?? {})
.filter((key) => key.startsWith("./plugin-sdk/"))
.map((key) => key.slice("./plugin-sdk/".length))
.filter((subpath) => Boolean(subpath) && !subpath.includes("/"))
.toSorted();
cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths);
return subpaths;
} catch {
return [];
}
const subpaths = readPluginSdkSubpathsFromPackageRoot(packageRoot) ?? [];
cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths);
return subpaths;
}
export function resolvePluginSdkScopedAliasMap(