From 5400980305f64745dda12c765882bd5aec5b40f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 3 Apr 2026 11:34:04 +0100 Subject: [PATCH] test(plugin-sdk): tighten boundary guardrails --- docs/plugins/architecture.md | 6 +- docs/plugins/sdk-migration.md | 2 +- src/plugin-sdk/channel-runtime.ts | 7 +- .../contracts/plugin-sdk-subpaths.test.ts | 67 ++++++++++++++++++- test/extension-test-boundary.test.ts | 10 ++- 5 files changed, 83 insertions(+), 9 deletions(-) diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 61436ccf223..75695e2c718 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -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`, diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index c6148bc3316..464969503b8 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -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 | diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index d18afbb57c1..17a5a678be9 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -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"; diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index f677f31d540..4c0d9174045 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -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(); 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", diff --git a/test/extension-test-boundary.test.ts b/test/extension-test-boundary.test.ts index 65f759d9b80..834299ba686 100644 --- a/test/extension-test-boundary.test.ts +++ b/test/extension-test-boundary.test.ts @@ -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; }