test(plugin-sdk): tighten boundary guardrails

This commit is contained in:
Peter Steinberger
2026-04-03 11:34:04 +01:00
parent 1c26e806ff
commit 5400980305
5 changed files with 83 additions and 9 deletions

View File

@@ -1004,8 +1004,10 @@ authoring plugins:
contract on the plugin. Core then reads approval auth, delivery, render, and
native-routing behavior through that one capability instead of mixing
approval behavior into unrelated plugin fields.
- `openclaw/plugin-sdk/channel-runtime` remains only as a compatibility shim.
New code should import the narrower primitives instead.
- `openclaw/plugin-sdk/channel-runtime` is deprecated and remains only as a
compatibility shim for older plugins. New code should import the narrower
generic primitives instead, and repo code should not add new imports of the
shim.
- Bundled extension internals remain private. External plugins should use only
`openclaw/plugin-sdk/*` subpaths. OpenClaw core/test code may use the repo
public entry points under a plugin package root such as `index.js`, `api.js`,

View File

@@ -155,7 +155,7 @@ bundled plugin workspace, keep provider-owned helpers in that plugin's own
| `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types |
| `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` |
| `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` |
| `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities |
| `plugin-sdk/channel-runtime` | Deprecated compatibility shim | Legacy channel runtime utilities only |
| `plugin-sdk/channel-send-result` | Send result types | Reply result types |
| `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` |
| `plugin-sdk/approval-runtime` | Approval prompt helpers | Exec/plugin approval payload, approval capability/profile helpers, native approval routing/runtime helpers |

View File

@@ -1,5 +1,8 @@
// Legacy compatibility shim for older channel helpers. Prefer the dedicated
// plugin-sdk subpaths instead of adding new imports here.
/**
* @deprecated Compatibility shim only. Keep old plugins working, but do not
* add new imports here and do not use this subpath from repo code.
* Prefer the dedicated generic plugin-sdk subpaths instead.
*/
export * from "../channels/chat-type.js";
export * from "../channels/reply-prefix.js";

View File

@@ -1,4 +1,4 @@
import { readFileSync } from "node:fs";
import { readFileSync, readdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type {
@@ -45,8 +45,9 @@ import { pluginSdkSubpaths } from "../../plugin-sdk/entrypoints.js";
import type { PluginRuntime } from "../runtime/types.js";
import type { OpenClawPluginApi } from "../types.js";
const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const PLUGIN_SDK_DIR = resolve(ROOT_DIR, "plugin-sdk");
const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const REPO_ROOT = resolve(SRC_ROOT, "..");
const PLUGIN_SDK_DIR = resolve(SRC_ROOT, "plugin-sdk");
const sourceCache = new Map<string, string>();
const representativeRuntimeSmokeSubpaths = ["channel-runtime", "conversation-runtime"] as const;
@@ -63,6 +64,39 @@ function readPluginSdkSource(subpath: string): string {
return text;
}
function listRepoTsFiles(dir: string): string[] {
const entries = readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const absolute = resolve(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "dist" || entry.name === "node_modules") {
return [];
}
return listRepoTsFiles(absolute);
}
if (!entry.isFile()) {
return [];
}
return absolute.endsWith(".ts") ? [absolute] : [];
});
}
function findRepoFilesContaining(params: {
roots: readonly string[];
pattern: RegExp;
exclude?: readonly string[];
excludeFilesMatching?: readonly RegExp[];
}) {
const excluded = new Set((params.exclude ?? []).map((entry) => resolve(REPO_ROOT, entry)));
return params.roots
.flatMap((root) => listRepoTsFiles(root))
.filter((file) => !excluded.has(file))
.filter((file) => !(params.excludeFilesMatching ?? []).some((pattern) => pattern.test(file)))
.filter((file) => params.pattern.test(readFileSync(file, "utf8")))
.map((file) => file.slice(REPO_ROOT.length + 1))
.toSorted();
}
function isIdentifierCode(code: number): boolean {
return (
(code >= 48 && code <= 57) ||
@@ -321,6 +355,33 @@ describe("plugin-sdk subpath exports", () => {
);
});
it("keeps the deprecated channel-runtime shim unused in repo imports", () => {
const matches = findRepoFilesContaining({
roots: [
resolve(REPO_ROOT, "src"),
resolve(REPO_ROOT, "extensions"),
resolve(REPO_ROOT, "test"),
],
pattern: /openclaw\/plugin-sdk\/channel-runtime/u,
exclude: ["src/plugins/sdk-alias.test.ts"],
});
expect(matches).toEqual([]);
});
it("keeps removed channel-named runtime boundaries out of core imports", () => {
const matches = findRepoFilesContaining({
roots: [resolve(REPO_ROOT, "src")],
pattern:
/plugins\/runtime\/runtime-(?:discord|imessage|line|signal|slack|telegram|whatsapp)(?:[-.][^"']*)?\.js/u,
exclude: [
"src/plugins/runtime/runtime-plugin-boundary.ts",
"src/plugins/runtime/runtime-web-channel-boundary.ts",
],
excludeFilesMatching: [/\.test\.ts$/u, /\.test-harness\.ts$/u],
});
expect(matches).toEqual([]);
});
it("exports channel runtime helpers from the dedicated subpath", () => {
expectSourceOmits("channel-runtime", [
"applyChannelMatchMeta",

View File

@@ -41,6 +41,12 @@ function findExtensionImports(source: string): string[] {
].map((match) => match[1]);
}
function isAllowedExtensionPublicImport(specifier: string): boolean {
return /(?:^|\/)extensions\/[^/]+\/(?:api|index|runtime-api|setup-entry|login-qr-api)\.js$/u.test(
specifier,
);
}
function findPluginSdkImports(source: string): string[] {
return [
...source.matchAll(/from\s+["']((?:\.\.\/)+plugin-sdk\/[^"']+)["']/g),
@@ -78,7 +84,9 @@ describe("non-extension test boundaries", () => {
const offenders = testFiles
.map((file) => {
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
const imports = findExtensionImports(source);
const imports = findExtensionImports(source).filter(
(specifier) => !isAllowedExtensionPublicImport(specifier),
);
if (imports.length === 0) {
return null;
}