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:
parent
3abffe0967
commit
a2a9a553e1
45
.github/workflows/install-smoke.yml
vendored
45
.github/workflows/install-smoke.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user