mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-04 22:01:15 +00:00
test: move remaining plugin-sdk guardrails to contracts
This commit is contained in:
@@ -1,599 +0,0 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BUNDLED_PLUGIN_PATH_PREFIX,
|
||||
BUNDLED_PLUGIN_ROOT_DIR,
|
||||
bundledPluginFile,
|
||||
} from "../../test/helpers/bundled-plugin-paths.js";
|
||||
import { GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES } from "../plugins/public-artifacts.js";
|
||||
|
||||
const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const REPO_ROOT = resolve(ROOT_DIR, "..");
|
||||
const ALLOWED_EXTENSION_PUBLIC_SURFACES = new Set(GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES);
|
||||
ALLOWED_EXTENSION_PUBLIC_SURFACES.add("test-api.js");
|
||||
const BUNDLED_EXTENSION_IDS = readdirSync(resolve(REPO_ROOT, "extensions"), { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name !== "shared")
|
||||
.map((entry) => entry.name)
|
||||
.toSorted((left, right) => right.length - left.length);
|
||||
const GUARDED_CHANNEL_EXTENSIONS = new Set([
|
||||
"bluebubbles",
|
||||
"discord",
|
||||
"feishu",
|
||||
"googlechat",
|
||||
"imessage",
|
||||
"irc",
|
||||
"line",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"msteams",
|
||||
"nostr",
|
||||
"nextcloud-talk",
|
||||
"signal",
|
||||
"slack",
|
||||
"synology-chat",
|
||||
"telegram",
|
||||
"tlon",
|
||||
"twitch",
|
||||
"whatsapp",
|
||||
"zalo",
|
||||
"zalouser",
|
||||
]);
|
||||
|
||||
type GuardedSource = {
|
||||
path: string;
|
||||
forbiddenPatterns: RegExp[];
|
||||
};
|
||||
|
||||
const SAME_CHANNEL_SDK_GUARDS: GuardedSource[] = [
|
||||
{
|
||||
path: bundledPluginFile("discord", "src/shared.ts"),
|
||||
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/discord["']/, /plugin-sdk-internal\/discord/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("slack", "src/shared.ts"),
|
||||
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/slack["']/, /plugin-sdk-internal\/slack/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/shared.ts"),
|
||||
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/telegram["']/, /plugin-sdk-internal\/telegram/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/account-inspect.ts"),
|
||||
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/account-resolution["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/accounts.ts"),
|
||||
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/account-resolution["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/token.ts"),
|
||||
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/account-resolution["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/channel.ts"),
|
||||
forbiddenPatterns: [/["']\.\.\/runtime-api\.js["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/action-runtime.ts"),
|
||||
forbiddenPatterns: [/["']\.\.\/runtime-api\.js["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/accounts.ts"),
|
||||
forbiddenPatterns: [/["']\.\.\/runtime-api\.js["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/account-inspect.ts"),
|
||||
forbiddenPatterns: [/["']\.\.\/runtime-api\.js["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/api-fetch.ts"),
|
||||
forbiddenPatterns: [/["']\.\.\/runtime-api\.js["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/channel.setup.ts"),
|
||||
forbiddenPatterns: [/["']\.\.\/runtime-api\.js["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/probe.ts"),
|
||||
forbiddenPatterns: [/["']\.\.\/runtime-api\.js["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/setup-core.ts"),
|
||||
forbiddenPatterns: [/["']\.\.\/runtime-api\.js["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/token.ts"),
|
||||
forbiddenPatterns: [/["']\.\.\/runtime-api\.js["']/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("imessage", "src/shared.ts"),
|
||||
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/imessage["']/, /plugin-sdk-internal\/imessage/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("whatsapp", "src/shared.ts"),
|
||||
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/whatsapp["']/, /plugin-sdk-internal\/whatsapp/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("signal", "src/shared.ts"),
|
||||
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/signal["']/, /plugin-sdk-internal\/signal/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("signal", "src/runtime-api.ts"),
|
||||
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/signal["']/, /plugin-sdk-internal\/signal/],
|
||||
},
|
||||
];
|
||||
|
||||
const SETUP_BARREL_GUARDS: GuardedSource[] = [
|
||||
{
|
||||
path: bundledPluginFile("signal", "src/setup-core.ts"),
|
||||
forbiddenPatterns: [/\bformatCliCommand\b/, /\bformatDocsLink\b/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("signal", "src/setup-surface.ts"),
|
||||
forbiddenPatterns: [
|
||||
/\bdetectBinary\b/,
|
||||
/\binstallSignalCli\b/,
|
||||
/\bformatCliCommand\b/,
|
||||
/\bformatDocsLink\b/,
|
||||
],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("slack", "src/setup-core.ts"),
|
||||
forbiddenPatterns: [/\bformatDocsLink\b/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("slack", "src/setup-surface.ts"),
|
||||
forbiddenPatterns: [/\bformatDocsLink\b/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("discord", "src/setup-core.ts"),
|
||||
forbiddenPatterns: [/\bformatDocsLink\b/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("discord", "src/setup-surface.ts"),
|
||||
forbiddenPatterns: [/\bformatDocsLink\b/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("imessage", "src/setup-core.ts"),
|
||||
forbiddenPatterns: [/\bformatDocsLink\b/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("imessage", "src/setup-surface.ts"),
|
||||
forbiddenPatterns: [/\bdetectBinary\b/, /\bformatDocsLink\b/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("telegram", "src/setup-core.ts"),
|
||||
forbiddenPatterns: [/\bformatCliCommand\b/, /\bformatDocsLink\b/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("whatsapp", "src/setup-surface.ts"),
|
||||
forbiddenPatterns: [/\bformatCliCommand\b/, /\bformatDocsLink\b/],
|
||||
},
|
||||
];
|
||||
|
||||
const CHANNEL_CONFIG_SCHEMA_GUARDS: GuardedSource[] = [
|
||||
{
|
||||
path: bundledPluginFile("tlon", "src/config-schema.ts"),
|
||||
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/core["']/],
|
||||
},
|
||||
];
|
||||
|
||||
const LOCAL_EXTENSION_API_BARREL_GUARDS = [
|
||||
"acpx",
|
||||
"bluebubbles",
|
||||
"device-pair",
|
||||
"diagnostics-otel",
|
||||
"discord",
|
||||
"diffs",
|
||||
"feishu",
|
||||
"google",
|
||||
"imessage",
|
||||
"irc",
|
||||
"llm-task",
|
||||
"line",
|
||||
"lobster",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"memory-lancedb",
|
||||
"msteams",
|
||||
"nextcloud-talk",
|
||||
"nostr",
|
||||
"ollama",
|
||||
"open-prose",
|
||||
"phone-control",
|
||||
"copilot-proxy",
|
||||
"sglang",
|
||||
"zai",
|
||||
"signal",
|
||||
"synology-chat",
|
||||
"talk-voice",
|
||||
"telegram",
|
||||
"thread-ownership",
|
||||
"tlon",
|
||||
"voice-call",
|
||||
"vllm",
|
||||
"whatsapp",
|
||||
"twitch",
|
||||
"xai",
|
||||
"zalo",
|
||||
"zalouser",
|
||||
] as const;
|
||||
|
||||
const LOCAL_EXTENSION_API_BARREL_EXCEPTIONS = [
|
||||
// Direct import avoids a circular init path:
|
||||
// accounts.ts -> runtime-api.ts -> src/plugin-sdk/matrix -> plugin api barrel -> accounts.ts
|
||||
bundledPluginFile("matrix", "src/matrix/accounts.ts"),
|
||||
] as const;
|
||||
|
||||
const sourceTextCache = new Map<string, string>();
|
||||
type SourceAnalysis = {
|
||||
text: string;
|
||||
importSpecifiers: string[];
|
||||
extensionImports: string[];
|
||||
};
|
||||
const sourceAnalysisCache = new Map<string, SourceAnalysis>();
|
||||
let extensionSourceFilesCache: string[] | null = null;
|
||||
let coreSourceFilesCache: string[] | null = null;
|
||||
const extensionFilesCache = new Map<string, string[]>();
|
||||
|
||||
type SourceFileCollectorOptions = {
|
||||
rootDir: string;
|
||||
shouldSkipPath?: (normalizedFullPath: string) => boolean;
|
||||
shouldSkipEntry?: (params: { entryName: string; normalizedFullPath: string }) => boolean;
|
||||
};
|
||||
|
||||
function readSource(path: string): string {
|
||||
const fullPath = resolve(REPO_ROOT, path);
|
||||
const cached = sourceTextCache.get(fullPath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const text = readFileSync(fullPath, "utf8");
|
||||
sourceTextCache.set(fullPath, text);
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function collectSourceFiles(
|
||||
cached: string[] | undefined | null,
|
||||
options: SourceFileCollectorOptions,
|
||||
): string[] {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const files: string[] = [];
|
||||
const stack = [options.rootDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (options.shouldSkipPath?.(normalizedFullPath)) {
|
||||
continue;
|
||||
}
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !/\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith(".d.ts")) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
options.shouldSkipPath?.(normalizedFullPath) ||
|
||||
options.shouldSkipEntry?.({ entryName: entry.name, normalizedFullPath })
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function readSetupBarrelImportBlock(path: string): string {
|
||||
const lines = readSource(path).split("\n");
|
||||
const targetLineIndex = lines.findIndex((line) =>
|
||||
/from\s*"[^"]*plugin-sdk(?:-internal)?\/setup(?:\.js)?";/.test(line),
|
||||
);
|
||||
if (targetLineIndex === -1) {
|
||||
return "";
|
||||
}
|
||||
let startLineIndex = targetLineIndex;
|
||||
while (startLineIndex >= 0 && !lines[startLineIndex].includes("import")) {
|
||||
startLineIndex -= 1;
|
||||
}
|
||||
return lines.slice(startLineIndex, targetLineIndex + 1).join("\n");
|
||||
}
|
||||
|
||||
function collectExtensionSourceFiles(): string[] {
|
||||
const extensionsDir = normalizePath(resolve(ROOT_DIR, "..", "extensions"));
|
||||
const sharedExtensionsDir = normalizePath(resolve(extensionsDir, "shared"));
|
||||
extensionSourceFilesCache = collectSourceFiles(extensionSourceFilesCache, {
|
||||
rootDir: resolve(ROOT_DIR, "..", "extensions"),
|
||||
shouldSkipPath: (normalizedFullPath) =>
|
||||
normalizedFullPath.includes(sharedExtensionsDir) ||
|
||||
normalizedFullPath.includes(`${extensionsDir}/shared/`),
|
||||
shouldSkipEntry: ({ entryName, normalizedFullPath }) =>
|
||||
normalizedFullPath.includes(".test.") ||
|
||||
normalizedFullPath.includes(".test-") ||
|
||||
normalizedFullPath.includes(".fixture.") ||
|
||||
normalizedFullPath.includes(".snap") ||
|
||||
normalizedFullPath.includes("test-support") ||
|
||||
entryName === "api.ts" ||
|
||||
entryName === "runtime-api.ts",
|
||||
});
|
||||
return extensionSourceFilesCache;
|
||||
}
|
||||
|
||||
function collectCoreSourceFiles(): string[] {
|
||||
const srcDir = resolve(ROOT_DIR, "..", "src");
|
||||
const normalizedPluginSdkDir = normalizePath(resolve(ROOT_DIR, "plugin-sdk"));
|
||||
coreSourceFilesCache = collectSourceFiles(coreSourceFilesCache, {
|
||||
rootDir: srcDir,
|
||||
shouldSkipEntry: ({ entryName, normalizedFullPath }) =>
|
||||
normalizedFullPath.includes(".test.") ||
|
||||
normalizedFullPath.includes(".test-utils.") ||
|
||||
normalizedFullPath.includes(".test-harness.") ||
|
||||
normalizedFullPath.includes(".test-helpers.") ||
|
||||
entryName.endsWith("-test-helpers.ts") ||
|
||||
entryName === "test-manager-helpers.ts" ||
|
||||
normalizedFullPath.includes(".mock-harness.") ||
|
||||
normalizedFullPath.includes(".suite.") ||
|
||||
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.
|
||||
normalizedFullPath.includes(`${normalizedPluginSdkDir}/`),
|
||||
});
|
||||
return coreSourceFilesCache;
|
||||
}
|
||||
|
||||
function collectExtensionFiles(extensionId: string): string[] {
|
||||
const cached = extensionFilesCache.get(extensionId);
|
||||
const files = collectSourceFiles(cached, {
|
||||
rootDir: resolve(ROOT_DIR, "..", "extensions", extensionId),
|
||||
shouldSkipEntry: ({ entryName, normalizedFullPath }) =>
|
||||
normalizedFullPath.includes(".test.") ||
|
||||
normalizedFullPath.includes(".test-") ||
|
||||
normalizedFullPath.includes(".spec.") ||
|
||||
normalizedFullPath.includes(".fixture.") ||
|
||||
normalizedFullPath.includes(".snap") ||
|
||||
normalizedFullPath.includes("test-support") ||
|
||||
entryName === "test-support.ts" ||
|
||||
entryName === "runtime-api.ts",
|
||||
});
|
||||
extensionFilesCache.set(extensionId, files);
|
||||
return files;
|
||||
}
|
||||
|
||||
function collectModuleSpecifiers(text: string): string[] {
|
||||
const patterns = [
|
||||
/\bimport\s*\(\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']\s*\)/g,
|
||||
/\brequire\s*\(\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']\s*\)/g,
|
||||
/\b(?:import|export)\b[\s\S]*?\bfrom\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']/g,
|
||||
/\bimport\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']/g,
|
||||
] as const;
|
||||
const specifiers = new Set<string>();
|
||||
for (const pattern of patterns) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const specifier = match[1]?.trim();
|
||||
if (specifier) {
|
||||
specifiers.add(specifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...specifiers];
|
||||
}
|
||||
|
||||
function collectImportSpecifiers(text: string): string[] {
|
||||
return collectModuleSpecifiers(text);
|
||||
}
|
||||
|
||||
function getSourceAnalysis(path: string): SourceAnalysis {
|
||||
const fullPath = resolve(REPO_ROOT, path);
|
||||
const cached = sourceAnalysisCache.get(fullPath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const text = readSource(path);
|
||||
const importSpecifiers = collectImportSpecifiers(text);
|
||||
const analysis = {
|
||||
text,
|
||||
importSpecifiers,
|
||||
extensionImports: importSpecifiers.filter((specifier) =>
|
||||
specifier.includes(BUNDLED_PLUGIN_PATH_PREFIX),
|
||||
),
|
||||
} satisfies SourceAnalysis;
|
||||
sourceAnalysisCache.set(fullPath, analysis);
|
||||
return analysis;
|
||||
}
|
||||
|
||||
function expectOnlyApprovedExtensionSeams(file: string, imports: string[]): void {
|
||||
for (const specifier of imports) {
|
||||
const normalized = specifier.replaceAll("\\", "/");
|
||||
const resolved = specifier.startsWith(".")
|
||||
? resolve(dirname(file), specifier).replaceAll("\\", "/")
|
||||
: normalized;
|
||||
const extensionId =
|
||||
resolved.match(new RegExp(`${BUNDLED_PLUGIN_ROOT_DIR}/([^/]+)/`))?.[1] ?? null;
|
||||
if (!extensionId || !GUARDED_CHANNEL_EXTENSIONS.has(extensionId)) {
|
||||
continue;
|
||||
}
|
||||
const basename = resolved.split("/").at(-1) ?? "";
|
||||
expect(
|
||||
ALLOWED_EXTENSION_PUBLIC_SURFACES.has(basename),
|
||||
`${file} should only import approved extension surfaces, got ${specifier}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
function expectNoSiblingExtensionPrivateSrcImports(file: string, imports: string[]): void {
|
||||
const normalizedFile = file.replaceAll("\\", "/");
|
||||
const currentExtensionId =
|
||||
normalizedFile.match(new RegExp(`/${BUNDLED_PLUGIN_ROOT_DIR}/([^/]+)/`))?.[1] ?? null;
|
||||
if (!currentExtensionId) {
|
||||
return;
|
||||
}
|
||||
for (const specifier of imports) {
|
||||
if (!specifier.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
const resolvedImport = resolve(dirname(file), specifier).replaceAll("\\", "/");
|
||||
const targetExtensionId = resolvedImport.match(/\/extensions\/([^/]+)\/src\//)?.[1] ?? null;
|
||||
if (!targetExtensionId || targetExtensionId === currentExtensionId) {
|
||||
continue;
|
||||
}
|
||||
expect.fail(`${file} should not import another extension's private src, got ${specifier}`);
|
||||
}
|
||||
}
|
||||
|
||||
function expectNoCrossPluginSdkFacadeImports(file: string, imports: string[]): void {
|
||||
const normalizedFile = file.replaceAll("\\", "/");
|
||||
const currentExtensionId =
|
||||
normalizedFile.match(new RegExp(`/${BUNDLED_PLUGIN_ROOT_DIR}/([^/]+)/`))?.[1] ?? null;
|
||||
if (!currentExtensionId) {
|
||||
return;
|
||||
}
|
||||
for (const specifier of imports) {
|
||||
if (!specifier.startsWith("openclaw/plugin-sdk/")) {
|
||||
continue;
|
||||
}
|
||||
const targetSubpath = specifier.slice("openclaw/plugin-sdk/".length);
|
||||
const targetExtensionId =
|
||||
BUNDLED_EXTENSION_IDS.find(
|
||||
(extensionId) =>
|
||||
targetSubpath === extensionId || targetSubpath.startsWith(`${extensionId}-`),
|
||||
) ?? null;
|
||||
if (!targetExtensionId || targetExtensionId === currentExtensionId) {
|
||||
continue;
|
||||
}
|
||||
expect.fail(
|
||||
`${file} should not import another bundled plugin facade, got ${specifier}. Promote shared helpers to a neutral plugin-sdk subpath instead.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe("channel import guardrails", () => {
|
||||
it("keeps channel helper modules off their own SDK barrels", () => {
|
||||
for (const source of SAME_CHANNEL_SDK_GUARDS) {
|
||||
const text = readSource(source.path);
|
||||
for (const pattern of source.forbiddenPatterns) {
|
||||
expect(text, `${source.path} should not match ${pattern}`).not.toMatch(pattern);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps setup barrels limited to setup primitives", () => {
|
||||
for (const source of SETUP_BARREL_GUARDS) {
|
||||
const importBlock = readSetupBarrelImportBlock(source.path);
|
||||
for (const pattern of source.forbiddenPatterns) {
|
||||
expect(importBlock, `${source.path} setup import should not match ${pattern}`).not.toMatch(
|
||||
pattern,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps channel config schemas off the broad core sdk barrel", () => {
|
||||
for (const source of CHANNEL_CONFIG_SCHEMA_GUARDS) {
|
||||
const text = readSource(source.path);
|
||||
for (const pattern of source.forbiddenPatterns) {
|
||||
expect(text, `${source.path} should not match ${pattern}`).not.toMatch(pattern);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps bundled extension source files off root and compat plugin-sdk imports", () => {
|
||||
for (const file of collectExtensionSourceFiles()) {
|
||||
const text = readSource(file);
|
||||
expect(text, `${file} should not import openclaw/plugin-sdk root`).not.toMatch(
|
||||
/["']openclaw\/plugin-sdk["']/,
|
||||
);
|
||||
expect(text, `${file} should not import openclaw/plugin-sdk/compat`).not.toMatch(
|
||||
/["']openclaw\/plugin-sdk\/compat["']/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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 = readSource(file);
|
||||
expect(text, `${file} should not import src/infra/outbound/send-deps.*`).not.toMatch(
|
||||
legacyCoreSendDepsImport,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps core production files off plugin-private src imports", () => {
|
||||
for (const file of collectCoreSourceFiles()) {
|
||||
const text = readSource(file);
|
||||
expect(text, `${file} should not import plugin-private src paths`).not.toMatch(
|
||||
/["'][^"']*extensions\/[^/"']+\/src\//,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps extension production files off other extensions' private src imports", () => {
|
||||
for (const file of collectExtensionSourceFiles()) {
|
||||
expectNoSiblingExtensionPrivateSrcImports(file, getSourceAnalysis(file).importSpecifiers);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps extension production files off other bundled plugin sdk facades", () => {
|
||||
for (const file of collectExtensionSourceFiles()) {
|
||||
expectNoCrossPluginSdkFacadeImports(file, getSourceAnalysis(file).importSpecifiers);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps core extension imports limited to approved public surfaces", () => {
|
||||
for (const file of collectCoreSourceFiles()) {
|
||||
expectOnlyApprovedExtensionSeams(file, getSourceAnalysis(file).extensionImports);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps extension-to-extension imports limited to approved public surfaces", () => {
|
||||
for (const file of collectExtensionSourceFiles()) {
|
||||
expectOnlyApprovedExtensionSeams(file, getSourceAnalysis(file).extensionImports);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps internalized extension helper surfaces behind local api barrels", () => {
|
||||
for (const extensionId of LOCAL_EXTENSION_API_BARREL_GUARDS) {
|
||||
for (const file of collectExtensionFiles(extensionId)) {
|
||||
const normalized = file.replaceAll("\\", "/");
|
||||
if (
|
||||
LOCAL_EXTENSION_API_BARREL_EXCEPTIONS.some((suffix) => normalized.endsWith(suffix)) ||
|
||||
normalized.endsWith("/api.ts") ||
|
||||
normalized.endsWith("/test-runtime.ts") ||
|
||||
normalized.includes(".test.") ||
|
||||
normalized.includes(".spec.") ||
|
||||
normalized.includes(".fixture.") ||
|
||||
normalized.includes(".snap")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const text = readSource(file);
|
||||
expect(
|
||||
text,
|
||||
`${normalized} should import ${extensionId} helpers via the local api barrel`,
|
||||
).not.toMatch(new RegExp(`["']openclaw/plugin-sdk/${extensionId}(?:["'/])`, "u"));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BUNDLED_PLUGIN_ROOT_DIR,
|
||||
bundledPluginFile,
|
||||
} from "../../test/helpers/bundled-plugin-paths.js";
|
||||
|
||||
const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const REPO_ROOT = resolve(ROOT_DIR, "..");
|
||||
const EXTENSIONS_DIR = resolve(REPO_ROOT, BUNDLED_PLUGIN_ROOT_DIR);
|
||||
const CORE_PLUGIN_ENTRY_IMPORT_RE =
|
||||
/import\s*\{[^}]*\bdefinePluginEntry\b[^}]*\}\s*from\s*"openclaw\/plugin-sdk\/core"/;
|
||||
const RUNTIME_ENTRY_HELPER_RE = /(^|\/)plugin-entry\.runtime\.[cm]?[jt]s$/;
|
||||
|
||||
describe("plugin entry guardrails", () => {
|
||||
it("keeps bundled extension entry modules off direct definePluginEntry imports from core", () => {
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const entry of readdirSync(EXTENSIONS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const indexPath = resolve(EXTENSIONS_DIR, entry.name, "index.ts");
|
||||
try {
|
||||
const source = readFileSync(indexPath, "utf8");
|
||||
if (CORE_PLUGIN_ENTRY_IMPORT_RE.test(source)) {
|
||||
failures.push(bundledPluginFile(entry.name, "index.ts"));
|
||||
}
|
||||
} catch {
|
||||
// Skip extensions without index.ts entry modules.
|
||||
}
|
||||
}
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not advertise runtime helper sidecars as bundled plugin entry extensions", () => {
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const entry of readdirSync(EXTENSIONS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const packageJsonPath = resolve(EXTENSIONS_DIR, entry.name, "package.json");
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
openclaw?: { extensions?: unknown };
|
||||
};
|
||||
const extensions = Array.isArray(pkg.openclaw?.extensions) ? pkg.openclaw.extensions : [];
|
||||
if (
|
||||
extensions.some(
|
||||
(candidate) => typeof candidate === "string" && RUNTIME_ENTRY_HELPER_RE.test(candidate),
|
||||
)
|
||||
) {
|
||||
failures.push(bundledPluginFile(entry.name, "package.json"));
|
||||
}
|
||||
} catch {
|
||||
// Skip directories without package metadata.
|
||||
}
|
||||
}
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bundledPluginFile } from "../../test/helpers/bundled-plugin-paths.js";
|
||||
|
||||
const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
|
||||
[bundledPluginFile("discord", "runtime-api.ts")]: [
|
||||
'export * from "./src/audit.js";',
|
||||
'export * from "./src/actions/runtime.js";',
|
||||
'export * from "./src/actions/runtime.moderation-shared.js";',
|
||||
'export * from "./src/actions/runtime.shared.js";',
|
||||
'export * from "./src/channel-actions.js";',
|
||||
'export * from "./src/directory-live.js";',
|
||||
'export * from "./src/monitor.js";',
|
||||
'export * from "./src/monitor/gateway-plugin.js";',
|
||||
'export * from "./src/monitor/gateway-registry.js";',
|
||||
'export * from "./src/monitor/presence-cache.js";',
|
||||
'export * from "./src/monitor/thread-bindings.js";',
|
||||
'export * from "./src/monitor/thread-bindings.manager.js";',
|
||||
'export * from "./src/monitor/timeouts.js";',
|
||||
'export * from "./src/probe.js";',
|
||||
'export * from "./src/resolve-channels.js";',
|
||||
'export * from "./src/resolve-users.js";',
|
||||
'export * from "./src/outbound-session-route.js";',
|
||||
'export * from "./src/send.js";',
|
||||
],
|
||||
[bundledPluginFile("imessage", "runtime-api.ts")]: [
|
||||
'export { DEFAULT_ACCOUNT_ID, buildChannelConfigSchema, getChatChannelMeta, type ChannelPlugin, type OpenClawConfig } from "openclaw/plugin-sdk/core";',
|
||||
'export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";',
|
||||
'export { buildComputedAccountStatusSnapshot, collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-helpers";',
|
||||
'export { formatTrimmedAllowFromEntries, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo } from "openclaw/plugin-sdk/channel-config-helpers";',
|
||||
'export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./src/normalize.js";',
|
||||
'export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";',
|
||||
'export { IMessageConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";',
|
||||
'export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy } from "./src/group-policy.js";',
|
||||
'export { monitorIMessageProvider } from "./src/monitor.js";',
|
||||
'export type { MonitorIMessageOpts } from "./src/monitor.js";',
|
||||
'export { probeIMessage } from "./src/probe.js";',
|
||||
'export type { IMessageProbe } from "./src/probe.js";',
|
||||
'export { sendMessageIMessage } from "./src/send.js";',
|
||||
'export type IMessageAccountConfig = Omit< NonNullable<NonNullable<RuntimeApiOpenClawConfig["channels"]>["imessage"]>, "accounts" | "defaultAccount" >;',
|
||||
'export function chunkTextForOutbound(text: string, limit: number): string[] { const chunks: string[] = []; let remaining = text; while (remaining.length > limit) { const window = remaining.slice(0, limit); const splitAt = Math.max(window.lastIndexOf("\\n"), window.lastIndexOf(" ")); const breakAt = splitAt > 0 ? splitAt : limit; chunks.push(remaining.slice(0, breakAt).trimEnd()); remaining = remaining.slice(breakAt).trimStart(); } if (remaining.length > 0 || text.length === 0) { chunks.push(remaining); } return chunks; }',
|
||||
],
|
||||
[bundledPluginFile("googlechat", "runtime-api.ts")]: [
|
||||
'export * from "openclaw/plugin-sdk/googlechat";',
|
||||
],
|
||||
[bundledPluginFile("matrix", "runtime-api.ts")]: [
|
||||
'export * from "./src/auth-precedence.js";',
|
||||
'export { requiresExplicitMatrixDefaultAccount, resolveMatrixDefaultOrOnlyAccountId } from "./src/account-selection.js";',
|
||||
'export * from "./src/account-selection.js";',
|
||||
'export * from "./src/env-vars.js";',
|
||||
'export * from "./src/storage-paths.js";',
|
||||
'export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";',
|
||||
'export { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey } from "./src/matrix/thread-bindings-shared.js";',
|
||||
'export { setMatrixRuntime } from "./src/runtime.js";',
|
||||
'export { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";',
|
||||
'export type { ChannelDirectoryEntry, ChannelMessageActionContext, OpenClawConfig, PluginRuntime, RuntimeLogger, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix-runtime-shared";',
|
||||
'export { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared";',
|
||||
'export function chunkTextForOutbound(text: string, limit: number): string[] { const chunks: string[] = []; let remaining = text; while (remaining.length > limit) { const window = remaining.slice(0, limit); const splitAt = Math.max(window.lastIndexOf("\\n"), window.lastIndexOf(" ")); const breakAt = splitAt > 0 ? splitAt : limit; chunks.push(remaining.slice(0, breakAt).trimEnd()); remaining = remaining.slice(breakAt).trimStart(); } if (remaining.length > 0 || text.length === 0) { chunks.push(remaining); } return chunks; }',
|
||||
],
|
||||
[bundledPluginFile("nextcloud-talk", "runtime-api.ts")]: [
|
||||
'export * from "openclaw/plugin-sdk/nextcloud-talk";',
|
||||
],
|
||||
[bundledPluginFile("signal", "runtime-api.ts")]: ['export * from "./src/runtime-api.js";'],
|
||||
[bundledPluginFile("slack", "runtime-api.ts")]: [
|
||||
'export * from "./src/action-runtime.js";',
|
||||
'export * from "./src/directory-live.js";',
|
||||
'export * from "./src/index.js";',
|
||||
'export * from "./src/resolve-channels.js";',
|
||||
'export * from "./src/resolve-users.js";',
|
||||
],
|
||||
[bundledPluginFile("telegram", "runtime-api.ts")]: [
|
||||
'export type { ChannelMessageActionAdapter, ChannelPlugin, OpenClawConfig, OpenClawPluginApi, PluginRuntime, TelegramAccountConfig, TelegramActionConfig, TelegramNetworkConfig } from "openclaw/plugin-sdk/telegram-core";',
|
||||
'export type { TelegramApiOverride } from "./src/send.js";',
|
||||
'export type { OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "openclaw/plugin-sdk/core";',
|
||||
'export type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeDoctorReport, AcpRuntimeEnsureInput, AcpRuntimeEvent, AcpRuntimeHandle, AcpRuntimeStatus, AcpRuntimeTurnInput, AcpRuntimeErrorCode, AcpSessionUpdateTag } from "openclaw/plugin-sdk/acp-runtime";',
|
||||
'export { AcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime";',
|
||||
'export { buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, normalizeAccountId, PAIRING_APPROVED_MESSAGE, parseTelegramTopicConversation, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, resolveTelegramPollVisibility } from "openclaw/plugin-sdk/telegram-core";',
|
||||
'export { buildChannelConfigSchema, getChatChannelMeta, jsonResult, readNumberParam, readReactionParams, readStringArrayParam, readStringOrNumberParam, readStringParam, resolvePollMaxSelections, TelegramConfigSchema } from "openclaw/plugin-sdk/telegram-core";',
|
||||
'export type { TelegramProbe } from "./src/probe.js";',
|
||||
'export { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./src/audit.js";',
|
||||
'export { resolveTelegramRuntimeGroupPolicy } from "./src/group-access.js";',
|
||||
'export { buildTelegramExecApprovalPendingPayload, shouldSuppressTelegramExecApprovalForwardingFallback } from "./src/exec-approval-forwarding.js";',
|
||||
'export { telegramMessageActions } from "./src/channel-actions.js";',
|
||||
'export { monitorTelegramProvider } from "./src/monitor.js";',
|
||||
'export { probeTelegram } from "./src/probe.js";',
|
||||
'export { resolveTelegramFetch, resolveTelegramTransport, shouldRetryTelegramTransportFallback } from "./src/fetch.js";',
|
||||
'export { makeProxyFetch } from "./src/proxy.js";',
|
||||
'export { createForumTopicTelegram, deleteMessageTelegram, editForumTopicTelegram, editMessageReplyMarkupTelegram, editMessageTelegram, pinMessageTelegram, reactMessageTelegram, renameForumTopicTelegram, sendMessageTelegram, sendPollTelegram, sendStickerTelegram, sendTypingTelegram, unpinMessageTelegram } from "./src/send.js";',
|
||||
'export { createTelegramThreadBindingManager, getTelegramThreadBindingManager, resetTelegramThreadBindingsForTests, setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey } from "./src/thread-bindings.js";',
|
||||
'export { resolveTelegramToken } from "./src/token.js";',
|
||||
],
|
||||
[bundledPluginFile("whatsapp", "runtime-api.ts")]: [
|
||||
'export * from "./src/active-listener.js";',
|
||||
'export * from "./src/action-runtime.js";',
|
||||
'export * from "./src/agent-tools-login.js";',
|
||||
'export * from "./src/auth-store.js";',
|
||||
'export * from "./src/auto-reply.js";',
|
||||
'export * from "./src/inbound.js";',
|
||||
'export * from "./src/login.js";',
|
||||
'export * from "./src/media.js";',
|
||||
'export * from "./src/send.js";',
|
||||
'export * from "./src/session.js";',
|
||||
"export async function startWebLoginWithQr( ...args: Parameters<StartWebLoginWithQr> ): ReturnType<StartWebLoginWithQr> { const { startWebLoginWithQr } = await loadLoginQrModule(); return await startWebLoginWithQr(...args); }",
|
||||
"export async function waitForWebLogin( ...args: Parameters<WaitForWebLogin> ): ReturnType<WaitForWebLogin> { const { waitForWebLogin } = await loadLoginQrModule(); return await waitForWebLogin(...args); }",
|
||||
],
|
||||
} as const;
|
||||
|
||||
function collectRuntimeApiFiles(): string[] {
|
||||
const extensionsDir = resolve(ROOT_DIR, "..", "extensions");
|
||||
const files: string[] = [];
|
||||
const stack = [extensionsDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
const fullPath = resolve(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") {
|
||||
continue;
|
||||
}
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || entry.name !== "runtime-api.ts") {
|
||||
continue;
|
||||
}
|
||||
files.push(relative(resolve(ROOT_DIR, ".."), fullPath).replaceAll("\\", "/"));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function readExportStatements(path: string): string[] {
|
||||
const sourceText = readFileSync(resolve(ROOT_DIR, "..", path), "utf8");
|
||||
const sourceFile = ts.createSourceFile(path, sourceText, ts.ScriptTarget.Latest, true);
|
||||
|
||||
return sourceFile.statements.flatMap((statement) => {
|
||||
if (!ts.isExportDeclaration(statement)) {
|
||||
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
|
||||
if (!modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
|
||||
return [];
|
||||
}
|
||||
return [statement.getText(sourceFile).replaceAll(/\s+/g, " ").trim()];
|
||||
}
|
||||
|
||||
const moduleSpecifier = statement.moduleSpecifier;
|
||||
if (!moduleSpecifier || !ts.isStringLiteral(moduleSpecifier)) {
|
||||
return [statement.getText(sourceFile).replaceAll(/\s+/g, " ").trim()];
|
||||
}
|
||||
|
||||
if (!statement.exportClause) {
|
||||
const prefix = statement.isTypeOnly ? "export type *" : "export *";
|
||||
return [`${prefix} from ${moduleSpecifier.getText(sourceFile)};`];
|
||||
}
|
||||
|
||||
if (!ts.isNamedExports(statement.exportClause)) {
|
||||
return [statement.getText(sourceFile).replaceAll(/\s+/g, " ").trim()];
|
||||
}
|
||||
|
||||
const specifiers = statement.exportClause.elements.map((element) => {
|
||||
const imported = element.propertyName?.text;
|
||||
const exported = element.name.text;
|
||||
const alias = imported ? `${imported} as ${exported}` : exported;
|
||||
return element.isTypeOnly ? `type ${alias}` : alias;
|
||||
});
|
||||
const exportPrefix = statement.isTypeOnly ? "export type" : "export";
|
||||
return [
|
||||
`${exportPrefix} { ${specifiers.join(", ")} } from ${moduleSpecifier.getText(sourceFile)};`,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
describe("runtime api guardrails", () => {
|
||||
it("keeps runtime api surfaces on an explicit export allowlist", () => {
|
||||
const runtimeApiFiles = collectRuntimeApiFiles();
|
||||
expect(runtimeApiFiles).toEqual(
|
||||
expect.arrayContaining(Object.keys(RUNTIME_API_EXPORT_GUARDS).toSorted()),
|
||||
);
|
||||
|
||||
for (const file of Object.keys(RUNTIME_API_EXPORT_GUARDS).toSorted()) {
|
||||
expect(readExportStatements(file), `${file} runtime api exports changed`).toEqual(
|
||||
RUNTIME_API_EXPORT_GUARDS[file],
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,812 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
BaseProbeResult as ContractBaseProbeResult,
|
||||
BaseTokenResolution as ContractBaseTokenResolution,
|
||||
ChannelAgentTool as ContractChannelAgentTool,
|
||||
ChannelAccountSnapshot as ContractChannelAccountSnapshot,
|
||||
ChannelGroupContext as ContractChannelGroupContext,
|
||||
ChannelMessageActionAdapter as ContractChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext as ContractChannelMessageActionContext,
|
||||
ChannelMessageActionName as ContractChannelMessageActionName,
|
||||
ChannelMessageToolDiscovery as ContractChannelMessageToolDiscovery,
|
||||
ChannelStatusIssue as ContractChannelStatusIssue,
|
||||
ChannelThreadingContext as ContractChannelThreadingContext,
|
||||
ChannelThreadingToolContext as ContractChannelThreadingToolContext,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type {
|
||||
ChannelMessageActionContext as CoreChannelMessageActionContext,
|
||||
OpenClawPluginApi as CoreOpenClawPluginApi,
|
||||
PluginRuntime as CorePluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import * as providerEntrySdk from "openclaw/plugin-sdk/provider-entry";
|
||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import type { ChannelMessageActionContext } from "../channels/plugins/types.js";
|
||||
import type {
|
||||
BaseProbeResult,
|
||||
BaseTokenResolution,
|
||||
ChannelAgentTool,
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
ChannelMessageToolDiscovery,
|
||||
ChannelStatusIssue,
|
||||
ChannelThreadingContext,
|
||||
ChannelThreadingToolContext,
|
||||
} from "../channels/plugins/types.js";
|
||||
import type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
import type { OpenClawPluginApi } from "../plugins/types.js";
|
||||
import type {
|
||||
ChannelMessageActionContext as SharedChannelMessageActionContext,
|
||||
OpenClawPluginApi as SharedOpenClawPluginApi,
|
||||
PluginRuntime as SharedPluginRuntime,
|
||||
} from "./channel-plugin-common.js";
|
||||
import { pluginSdkSubpaths } from "./entrypoints.js";
|
||||
|
||||
const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const PLUGIN_SDK_DIR = resolve(ROOT_DIR, "plugin-sdk");
|
||||
const sourceCache = new Map<string, string>();
|
||||
const representativeRuntimeSmokeSubpaths = ["channel-runtime", "conversation-runtime"] as const;
|
||||
|
||||
const importResolvedPluginSdkSubpath = async (specifier: string) => import(specifier);
|
||||
|
||||
function readPluginSdkSource(subpath: string): string {
|
||||
const file = resolve(PLUGIN_SDK_DIR, `${subpath}.ts`);
|
||||
const cached = sourceCache.get(file);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const text = readFileSync(file, "utf8");
|
||||
sourceCache.set(file, text);
|
||||
return text;
|
||||
}
|
||||
|
||||
function isIdentifierCode(code: number): boolean {
|
||||
return (
|
||||
(code >= 48 && code <= 57) ||
|
||||
(code >= 65 && code <= 90) ||
|
||||
(code >= 97 && code <= 122) ||
|
||||
code === 36 ||
|
||||
code === 95
|
||||
);
|
||||
}
|
||||
|
||||
function sourceMentionsIdentifier(source: string, name: string): boolean {
|
||||
let fromIndex = 0;
|
||||
while (true) {
|
||||
const matchIndex = source.indexOf(name, fromIndex);
|
||||
if (matchIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
const beforeCode = matchIndex === 0 ? -1 : source.charCodeAt(matchIndex - 1);
|
||||
const afterIndex = matchIndex + name.length;
|
||||
const afterCode = afterIndex >= source.length ? -1 : source.charCodeAt(afterIndex);
|
||||
if (!isIdentifierCode(beforeCode) && !isIdentifierCode(afterCode)) {
|
||||
return true;
|
||||
}
|
||||
fromIndex = matchIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function expectSourceMentions(subpath: string, names: readonly string[]) {
|
||||
const source = readPluginSdkSource(subpath);
|
||||
const missing = names.filter((name) => !sourceMentionsIdentifier(source, name));
|
||||
expect(missing, `${subpath} missing exports`).toEqual([]);
|
||||
}
|
||||
|
||||
function expectSourceOmits(subpath: string, names: readonly string[]) {
|
||||
const source = readPluginSdkSource(subpath);
|
||||
const present = names.filter((name) => sourceMentionsIdentifier(source, name));
|
||||
expect(present, `${subpath} leaked exports`).toEqual([]);
|
||||
}
|
||||
|
||||
function expectSourceContract(
|
||||
subpath: string,
|
||||
params: { mentions?: readonly string[]; omits?: readonly string[] },
|
||||
) {
|
||||
const source = readPluginSdkSource(subpath);
|
||||
const missing = (params.mentions ?? []).filter((name) => !sourceMentionsIdentifier(source, name));
|
||||
const present = (params.omits ?? []).filter((name) => sourceMentionsIdentifier(source, name));
|
||||
expect(missing, `${subpath} missing exports`).toEqual([]);
|
||||
expect(present, `${subpath} leaked exports`).toEqual([]);
|
||||
}
|
||||
|
||||
function expectSourceContains(subpath: string, snippet: string) {
|
||||
expect(readPluginSdkSource(subpath)).toContain(snippet);
|
||||
}
|
||||
|
||||
function expectSourceOmitsSnippet(subpath: string, snippet: string) {
|
||||
expect(readPluginSdkSource(subpath)).not.toContain(snippet);
|
||||
}
|
||||
|
||||
function expectSourceOmitsImportPattern(subpath: string, specifier: string) {
|
||||
const escapedSpecifier = specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const source = readPluginSdkSource(subpath);
|
||||
expect(source).not.toMatch(new RegExp(`\\bfrom\\s+["']${escapedSpecifier}["']`, "u"));
|
||||
expect(source).not.toMatch(new RegExp(`\\bimport\\(\\s*["']${escapedSpecifier}["']\\s*\\)`, "u"));
|
||||
}
|
||||
|
||||
describe("plugin-sdk subpath exports", () => {
|
||||
it("keeps the curated public list free of internal implementation subpaths", () => {
|
||||
for (const deniedSubpath of [
|
||||
"acpx",
|
||||
"device-pair",
|
||||
"lobster",
|
||||
"pairing-access",
|
||||
"provider-model-definitions",
|
||||
"reply-prefix",
|
||||
"secret-input-runtime",
|
||||
"secret-input-schema",
|
||||
"signal-core",
|
||||
"synology-chat",
|
||||
"typing",
|
||||
"whatsapp",
|
||||
"whatsapp-action-runtime",
|
||||
"whatsapp-login-qr",
|
||||
"zai",
|
||||
]) {
|
||||
expect(pluginSdkSubpaths).not.toContain(deniedSubpath);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps helper subpaths aligned", () => {
|
||||
expectSourceMentions("core", [
|
||||
"emptyPluginConfigSchema",
|
||||
"definePluginEntry",
|
||||
"defineChannelPluginEntry",
|
||||
"defineSetupPluginEntry",
|
||||
"createChatChannelPlugin",
|
||||
"createChannelPluginBase",
|
||||
"isSecretRef",
|
||||
"optionalStringEnum",
|
||||
]);
|
||||
expectSourceOmits("core", [
|
||||
"runPassiveAccountLifecycle",
|
||||
"createLoggerBackedRuntime",
|
||||
"registerSandboxBackend",
|
||||
]);
|
||||
expectSourceContract("routing", {
|
||||
mentions: [
|
||||
"buildAgentSessionKey",
|
||||
"resolveThreadSessionKeys",
|
||||
"normalizeMessageChannel",
|
||||
"resolveGatewayMessageChannel",
|
||||
],
|
||||
});
|
||||
expectSourceMentions("reply-payload", [
|
||||
"buildMediaPayload",
|
||||
"deliverTextOrMediaReply",
|
||||
"resolveOutboundMediaUrls",
|
||||
"resolvePayloadMediaUrls",
|
||||
"sendPayloadMediaSequenceAndFinalize",
|
||||
"sendPayloadMediaSequenceOrFallback",
|
||||
"sendTextMediaPayload",
|
||||
"sendPayloadWithChunkedTextAndMedia",
|
||||
]);
|
||||
expectSourceMentions("media-runtime", [
|
||||
"createDirectTextMediaOutbound",
|
||||
"createScopedChannelMediaMaxBytesResolver",
|
||||
]);
|
||||
expectSourceMentions("telegram-core", [
|
||||
"ChannelMessageActionAdapter",
|
||||
"TelegramAccountConfig",
|
||||
"buildChannelConfigSchema",
|
||||
"buildTokenChannelStatusSummary",
|
||||
"resolveConfiguredFromCredentialStatuses",
|
||||
]);
|
||||
expectSourceMentions("bluebubbles", [
|
||||
"normalizeBlueBubblesAcpConversationId",
|
||||
"matchBlueBubblesAcpConversation",
|
||||
"resolveBlueBubblesConversationIdFromTarget",
|
||||
"resolveAckReaction",
|
||||
"resolveChannelMediaMaxBytes",
|
||||
"collectBlueBubblesStatusIssues",
|
||||
"createChannelPairingController",
|
||||
"createChannelReplyPipeline",
|
||||
"resolveRequestUrl",
|
||||
"buildProbeChannelStatusSummary",
|
||||
"extractToolSend",
|
||||
"createFixedWindowRateLimiter",
|
||||
"withResolvedWebhookRequestPipeline",
|
||||
]);
|
||||
expectSourceMentions("irc", [
|
||||
"createChannelReplyPipeline",
|
||||
"chunkTextForOutbound",
|
||||
"createChannelPairingController",
|
||||
"createLoggerBackedRuntime",
|
||||
"ircSetupAdapter",
|
||||
"ircSetupWizard",
|
||||
]);
|
||||
expectSourceMentions("bluebubbles-policy", [
|
||||
"isAllowedBlueBubblesSender",
|
||||
"resolveBlueBubblesGroupRequireMention",
|
||||
"resolveBlueBubblesGroupToolPolicy",
|
||||
]);
|
||||
for (const subpath of [
|
||||
"feishu",
|
||||
"googlechat",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"msteams",
|
||||
"zalo",
|
||||
"zalouser",
|
||||
]) {
|
||||
expectSourceMentions(subpath, ["chunkTextForOutbound"]);
|
||||
}
|
||||
expectSourceMentions("signal", ["chunkText"]);
|
||||
expectSourceMentions("reply-history", [
|
||||
"buildPendingHistoryContextFromMap",
|
||||
"clearHistoryEntriesIfEnabled",
|
||||
"recordPendingHistoryEntryIfEnabled",
|
||||
]);
|
||||
expectSourceContract("reply-runtime", {
|
||||
omits: [
|
||||
"buildPendingHistoryContextFromMap",
|
||||
"clearHistoryEntriesIfEnabled",
|
||||
"recordPendingHistoryEntryIfEnabled",
|
||||
"DEFAULT_GROUP_HISTORY_LIMIT",
|
||||
],
|
||||
});
|
||||
expectSourceMentions("account-helpers", ["createAccountListHelpers"]);
|
||||
expectSourceMentions("channel-actions", ["optionalStringEnum", "stringEnum"]);
|
||||
expectSourceMentions("compat", [
|
||||
"createPluginRuntimeStore",
|
||||
"createScopedChannelConfigAdapter",
|
||||
"resolveControlCommandGate",
|
||||
"delegateCompactionToRuntime",
|
||||
]);
|
||||
expectSourceMentions("device-bootstrap", [
|
||||
"approveDevicePairing",
|
||||
"issueDeviceBootstrapToken",
|
||||
"listDevicePairing",
|
||||
]);
|
||||
expectSourceMentions("allowlist-config-edit", [
|
||||
"buildDmGroupAccountAllowlistAdapter",
|
||||
"createNestedAllowlistOverrideResolver",
|
||||
]);
|
||||
expectSourceContract("allow-from", {
|
||||
mentions: [
|
||||
"addAllowlistUserEntriesFromConfigEntry",
|
||||
"buildAllowlistResolutionSummary",
|
||||
"canonicalizeAllowlistWithResolvedIds",
|
||||
"mapAllowlistResolutionInputs",
|
||||
"mergeAllowlist",
|
||||
"patchAllowlistUsersInConfigEntries",
|
||||
"summarizeMapping",
|
||||
"compileAllowlist",
|
||||
"firstDefined",
|
||||
"formatAllowlistMatchMeta",
|
||||
"isSenderIdAllowed",
|
||||
"mergeDmAllowFromSources",
|
||||
"resolveAllowlistMatchSimple",
|
||||
],
|
||||
});
|
||||
expectSourceMentions("runtime", ["createLoggerBackedRuntime"]);
|
||||
expectSourceMentions("discord", [
|
||||
"buildDiscordComponentMessage",
|
||||
"editDiscordComponentMessage",
|
||||
"registerBuiltDiscordComponentMessage",
|
||||
"resolveDiscordAccount",
|
||||
]);
|
||||
expectSourceMentions("huggingface", [
|
||||
"buildHuggingfaceModelDefinition",
|
||||
"buildHuggingfaceProvider",
|
||||
"discoverHuggingfaceModels",
|
||||
"HUGGINGFACE_MODEL_CATALOG",
|
||||
"isHuggingfacePolicyLocked",
|
||||
]);
|
||||
expectSourceMentions("conversation-runtime", [
|
||||
"recordInboundSession",
|
||||
"recordInboundSessionMetaSafe",
|
||||
"resolveConversationLabel",
|
||||
]);
|
||||
expectSourceMentions("directory-runtime", [
|
||||
"createChannelDirectoryAdapter",
|
||||
"createRuntimeDirectoryLiveAdapter",
|
||||
"listDirectoryEntriesFromSources",
|
||||
"listResolvedDirectoryEntriesFromSources",
|
||||
]);
|
||||
expectSourceContains(
|
||||
"memory-core-host-runtime-core",
|
||||
'export * from "../../packages/memory-host-sdk/src/runtime-core.js";',
|
||||
);
|
||||
expectSourceContains(
|
||||
"memory-core-host-runtime-cli",
|
||||
'export * from "../../packages/memory-host-sdk/src/runtime-cli.js";',
|
||||
);
|
||||
expectSourceContains(
|
||||
"memory-core-host-runtime-files",
|
||||
'export * from "../../packages/memory-host-sdk/src/runtime-files.js";',
|
||||
);
|
||||
});
|
||||
|
||||
it("exports channel runtime helpers from the dedicated subpath", () => {
|
||||
expectSourceOmits("channel-runtime", [
|
||||
"applyChannelMatchMeta",
|
||||
"createChannelDirectoryAdapter",
|
||||
"createEmptyChannelDirectoryAdapter",
|
||||
"createArmableStallWatchdog",
|
||||
"createDraftStreamLoop",
|
||||
"createLoggedPairingApprovalNotifier",
|
||||
"createPairingPrefixStripper",
|
||||
"createRunStateMachine",
|
||||
"createRuntimeDirectoryLiveAdapter",
|
||||
"createRuntimeOutboundDelegates",
|
||||
"createStatusReactionController",
|
||||
"createTextPairingAdapter",
|
||||
"createFinalizableDraftLifecycle",
|
||||
"DEFAULT_EMOJIS",
|
||||
"logAckFailure",
|
||||
"logTypingFailure",
|
||||
"logInboundDrop",
|
||||
"normalizeMessageChannel",
|
||||
"removeAckReactionAfterReply",
|
||||
"recordInboundSession",
|
||||
"recordInboundSessionMetaSafe",
|
||||
"resolveInboundSessionEnvelopeContext",
|
||||
"resolveMentionGating",
|
||||
"resolveMentionGatingWithBypass",
|
||||
"resolveOutboundSendDep",
|
||||
"resolveConversationLabel",
|
||||
"shouldDebounceTextInbound",
|
||||
"shouldAckReaction",
|
||||
"shouldAckReactionForWhatsApp",
|
||||
"toLocationContext",
|
||||
"resolveThreadBindingConversationIdFromBindingId",
|
||||
"resolveThreadBindingEffectiveExpiresAt",
|
||||
"resolveThreadBindingFarewellText",
|
||||
"resolveThreadBindingIdleTimeoutMs",
|
||||
"resolveThreadBindingIdleTimeoutMsForChannel",
|
||||
"resolveThreadBindingIntroText",
|
||||
"resolveThreadBindingLifecycle",
|
||||
"resolveThreadBindingMaxAgeMs",
|
||||
"resolveThreadBindingMaxAgeMsForChannel",
|
||||
"resolveThreadBindingSpawnPolicy",
|
||||
"resolveThreadBindingThreadName",
|
||||
"resolveThreadBindingsEnabled",
|
||||
"formatThreadBindingDisabledError",
|
||||
"DISCORD_THREAD_BINDING_CHANNEL",
|
||||
"MATRIX_THREAD_BINDING_CHANNEL",
|
||||
"resolveControlCommandGate",
|
||||
"resolveCommandAuthorizedFromAuthorizers",
|
||||
"resolveDualTextControlCommandGate",
|
||||
"resolveNativeCommandSessionTargets",
|
||||
"attachChannelToResult",
|
||||
"buildComputedAccountStatusSnapshot",
|
||||
"buildMediaPayload",
|
||||
"createActionGate",
|
||||
"jsonResult",
|
||||
"normalizeInteractiveReply",
|
||||
"PAIRING_APPROVED_MESSAGE",
|
||||
"projectCredentialSnapshotFields",
|
||||
"readStringParam",
|
||||
"compileAllowlist",
|
||||
"formatAllowlistMatchMeta",
|
||||
"firstDefined",
|
||||
"isSenderIdAllowed",
|
||||
"mergeDmAllowFromSources",
|
||||
"addAllowlistUserEntriesFromConfigEntry",
|
||||
"buildAllowlistResolutionSummary",
|
||||
"canonicalizeAllowlistWithResolvedIds",
|
||||
"mergeAllowlist",
|
||||
"patchAllowlistUsersInConfigEntries",
|
||||
"resolvePayloadMediaUrls",
|
||||
"resolveScopedChannelMediaMaxBytes",
|
||||
"sendPayloadMediaSequenceAndFinalize",
|
||||
"sendPayloadMediaSequenceOrFallback",
|
||||
"sendTextMediaPayload",
|
||||
"createScopedChannelMediaMaxBytesResolver",
|
||||
"runPassiveAccountLifecycle",
|
||||
"buildChannelKeyCandidates",
|
||||
"buildMessagingTarget",
|
||||
"createDirectTextMediaOutbound",
|
||||
"createMessageToolButtonsSchema",
|
||||
"createMessageToolCardSchema",
|
||||
"createScopedAccountReplyToModeResolver",
|
||||
"createStaticReplyToModeResolver",
|
||||
"createTopLevelChannelReplyToModeResolver",
|
||||
"createUnionActionGate",
|
||||
"ensureTargetId",
|
||||
"listTokenSourcedAccounts",
|
||||
"parseMentionPrefixOrAtUserTarget",
|
||||
"requireTargetKind",
|
||||
"resolveChannelEntryMatchWithFallback",
|
||||
"resolveChannelMatchConfig",
|
||||
"resolveReactionMessageId",
|
||||
"resolveTargetsWithOptionalToken",
|
||||
"appendMatchMetadata",
|
||||
"asString",
|
||||
"collectIssuesForEnabledAccounts",
|
||||
"isRecord",
|
||||
"resolveEnabledConfiguredAccountId",
|
||||
]);
|
||||
expectSourceMentions("channel-inbound", [
|
||||
"buildMentionRegexes",
|
||||
"createDirectDmPreCryptoGuardPolicy",
|
||||
"createChannelInboundDebouncer",
|
||||
"createInboundDebouncer",
|
||||
"dispatchInboundDirectDmWithRuntime",
|
||||
"formatInboundEnvelope",
|
||||
"formatInboundFromLabel",
|
||||
"formatLocationText",
|
||||
"logInboundDrop",
|
||||
"matchesMentionPatterns",
|
||||
"matchesMentionWithExplicit",
|
||||
"normalizeMentionText",
|
||||
"resolveInboundDebounceMs",
|
||||
"resolveEnvelopeFormatOptions",
|
||||
"resolveInboundSessionEnvelopeContext",
|
||||
"resolveMentionGating",
|
||||
"resolveMentionGatingWithBypass",
|
||||
"shouldDebounceTextInbound",
|
||||
"toLocationContext",
|
||||
]);
|
||||
expectSourceContract("reply-runtime", {
|
||||
omits: [
|
||||
"buildMentionRegexes",
|
||||
"formatInboundEnvelope",
|
||||
"formatInboundFromLabel",
|
||||
"matchesMentionPatterns",
|
||||
"matchesMentionWithExplicit",
|
||||
"normalizeMentionText",
|
||||
"resolveEnvelopeFormatOptions",
|
||||
"hasControlCommand",
|
||||
"buildCommandTextFromArgs",
|
||||
"buildCommandsPaginationKeyboard",
|
||||
"buildModelsProviderData",
|
||||
"listNativeCommandSpecsForConfig",
|
||||
"listSkillCommandsForAgents",
|
||||
"normalizeCommandBody",
|
||||
"resolveCommandAuthorization",
|
||||
"resolveStoredModelOverride",
|
||||
"shouldComputeCommandAuthorized",
|
||||
"shouldHandleTextCommands",
|
||||
],
|
||||
});
|
||||
expectSourceMentions("channel-setup", [
|
||||
"createOptionalChannelSetupSurface",
|
||||
"createTopLevelChannelDmPolicy",
|
||||
]);
|
||||
expectSourceContract("channel-actions", {
|
||||
mentions: [
|
||||
"createUnionActionGate",
|
||||
"listTokenSourcedAccounts",
|
||||
"resolveReactionMessageId",
|
||||
"createMessageToolButtonsSchema",
|
||||
"createMessageToolCardSchema",
|
||||
],
|
||||
});
|
||||
expectSourceMentions("channel-targets", [
|
||||
"applyChannelMatchMeta",
|
||||
"buildChannelKeyCandidates",
|
||||
"buildMessagingTarget",
|
||||
"createAllowedChatSenderMatcher",
|
||||
"ensureTargetId",
|
||||
"parseChatAllowTargetPrefixes",
|
||||
"parseMentionPrefixOrAtUserTarget",
|
||||
"parseChatTargetPrefixesOrThrow",
|
||||
"requireTargetKind",
|
||||
"resolveChannelEntryMatchWithFallback",
|
||||
"resolveChannelMatchConfig",
|
||||
"resolveServicePrefixedAllowTarget",
|
||||
"resolveServicePrefixedChatTarget",
|
||||
"resolveServicePrefixedOrChatAllowTarget",
|
||||
"resolveServicePrefixedTarget",
|
||||
"resolveTargetsWithOptionalToken",
|
||||
]);
|
||||
expectSourceMentions("channel-config-writes", [
|
||||
"authorizeConfigWrite",
|
||||
"canBypassConfigWritePolicy",
|
||||
"formatConfigWriteDeniedMessage",
|
||||
"resolveChannelConfigWrites",
|
||||
]);
|
||||
expectSourceMentions("channel-feedback", [
|
||||
"createStatusReactionController",
|
||||
"logAckFailure",
|
||||
"logTypingFailure",
|
||||
"removeAckReactionAfterReply",
|
||||
"shouldAckReaction",
|
||||
"shouldAckReactionForWhatsApp",
|
||||
"DEFAULT_EMOJIS",
|
||||
]);
|
||||
expectSourceMentions("status-helpers", [
|
||||
"appendMatchMetadata",
|
||||
"asString",
|
||||
"collectIssuesForEnabledAccounts",
|
||||
"isRecord",
|
||||
"resolveEnabledConfiguredAccountId",
|
||||
]);
|
||||
expectSourceMentions("outbound-runtime", [
|
||||
"createRuntimeOutboundDelegates",
|
||||
"resolveOutboundSendDep",
|
||||
"resolveAgentOutboundIdentity",
|
||||
]);
|
||||
expectSourceMentions("command-auth", [
|
||||
"buildCommandTextFromArgs",
|
||||
"buildCommandsPaginationKeyboard",
|
||||
"buildModelsProviderData",
|
||||
"hasControlCommand",
|
||||
"listNativeCommandSpecsForConfig",
|
||||
"listSkillCommandsForAgents",
|
||||
"normalizeCommandBody",
|
||||
"createPreCryptoDirectDmAuthorizer",
|
||||
"resolveCommandAuthorization",
|
||||
"resolveCommandAuthorizedFromAuthorizers",
|
||||
"resolveInboundDirectDmAccessWithRuntime",
|
||||
"resolveControlCommandGate",
|
||||
"resolveDualTextControlCommandGate",
|
||||
"resolveNativeCommandSessionTargets",
|
||||
"resolveStoredModelOverride",
|
||||
"shouldComputeCommandAuthorized",
|
||||
"shouldHandleTextCommands",
|
||||
]);
|
||||
expectSourceMentions("channel-send-result", [
|
||||
"attachChannelToResult",
|
||||
"buildChannelSendResult",
|
||||
]);
|
||||
expectSourceMentions("direct-dm", [
|
||||
"createDirectDmPreCryptoGuardPolicy",
|
||||
"createPreCryptoDirectDmAuthorizer",
|
||||
"dispatchInboundDirectDmWithRuntime",
|
||||
"resolveInboundDirectDmAccessWithRuntime",
|
||||
]);
|
||||
|
||||
expectSourceMentions("conversation-runtime", [
|
||||
"DISCORD_THREAD_BINDING_CHANNEL",
|
||||
"MATRIX_THREAD_BINDING_CHANNEL",
|
||||
"formatThreadBindingDisabledError",
|
||||
"resolveThreadBindingFarewellText",
|
||||
"resolveThreadBindingConversationIdFromBindingId",
|
||||
"resolveThreadBindingEffectiveExpiresAt",
|
||||
"resolveThreadBindingIdleTimeoutMs",
|
||||
"resolveThreadBindingIdleTimeoutMsForChannel",
|
||||
"resolveThreadBindingIntroText",
|
||||
"resolveThreadBindingLifecycle",
|
||||
"resolveThreadBindingMaxAgeMs",
|
||||
"resolveThreadBindingMaxAgeMsForChannel",
|
||||
"resolveThreadBindingSpawnPolicy",
|
||||
"resolveThreadBindingThreadName",
|
||||
"resolveThreadBindingsEnabled",
|
||||
"formatThreadBindingDurationLabel",
|
||||
"createScopedAccountReplyToModeResolver",
|
||||
"createStaticReplyToModeResolver",
|
||||
"createTopLevelChannelReplyToModeResolver",
|
||||
]);
|
||||
|
||||
expectSourceMentions("thread-bindings-runtime", [
|
||||
"resolveThreadBindingFarewellText",
|
||||
"resolveThreadBindingLifecycle",
|
||||
"registerSessionBindingAdapter",
|
||||
"unregisterSessionBindingAdapter",
|
||||
"SessionBindingAdapter",
|
||||
]);
|
||||
expectSourceMentions("matrix-runtime-shared", ["formatZonedTimestamp"]);
|
||||
expectSourceMentions("ssrf-runtime", [
|
||||
"closeDispatcher",
|
||||
"createPinnedDispatcher",
|
||||
"resolvePinnedHostnameWithPolicy",
|
||||
"formatErrorMessage",
|
||||
"assertHttpUrlTargetsPrivateNetwork",
|
||||
"ssrfPolicyFromAllowPrivateNetwork",
|
||||
]);
|
||||
|
||||
expectSourceContract("provider-setup", {
|
||||
mentions: [
|
||||
"applyProviderDefaultModel",
|
||||
"discoverOpenAICompatibleLocalModels",
|
||||
"discoverOpenAICompatibleSelfHostedProvider",
|
||||
],
|
||||
omits: [
|
||||
"buildOllamaProvider",
|
||||
"configureOllamaNonInteractive",
|
||||
"ensureOllamaModelPulled",
|
||||
"promptAndConfigureOllama",
|
||||
"promptAndConfigureVllm",
|
||||
"buildVllmProvider",
|
||||
"buildSglangProvider",
|
||||
"OLLAMA_DEFAULT_BASE_URL",
|
||||
"OLLAMA_DEFAULT_MODEL",
|
||||
"VLLM_DEFAULT_BASE_URL",
|
||||
],
|
||||
});
|
||||
expectSourceOmitsSnippet("provider-setup", "./ollama-surface.js");
|
||||
expectSourceOmitsImportPattern("provider-setup", "./vllm.js");
|
||||
expectSourceOmitsImportPattern("provider-setup", "./sglang.js");
|
||||
expectSourceMentions("provider-auth", [
|
||||
"buildOauthProviderAuthResult",
|
||||
"generatePkceVerifierChallenge",
|
||||
"readClaudeCliCredentialsCached",
|
||||
"toFormUrlEncoded",
|
||||
]);
|
||||
expectSourceOmits("core", ["buildOauthProviderAuthResult"]);
|
||||
expectSourceContract("provider-model-shared", {
|
||||
mentions: ["DEFAULT_CONTEXT_TOKENS", "normalizeModelCompat", "cloneFirstTemplateModel"],
|
||||
omits: ["applyOpenAIConfig", "buildKilocodeModelDefinition", "discoverHuggingfaceModels"],
|
||||
});
|
||||
expectSourceContract("provider-catalog-shared", {
|
||||
mentions: ["buildSingleProviderApiKeyCatalog", "buildPairedProviderApiKeyCatalog"],
|
||||
omits: ["buildDeepSeekProvider", "buildOpenAICodexProvider", "buildVeniceProvider"],
|
||||
});
|
||||
|
||||
expectSourceMentions("setup", [
|
||||
"DEFAULT_ACCOUNT_ID",
|
||||
"createAllowFromSection",
|
||||
"createDelegatedSetupWizardProxy",
|
||||
"createTopLevelChannelDmPolicy",
|
||||
"mergeAllowFromEntries",
|
||||
]);
|
||||
expectSourceMentions("setup-tools", [
|
||||
"formatCliCommand",
|
||||
"detectBinary",
|
||||
"installSignalCli",
|
||||
"formatDocsLink",
|
||||
]);
|
||||
expectSourceMentions("lazy-runtime", ["createLazyRuntimeSurface", "createLazyRuntimeModule"]);
|
||||
expectSourceContract("self-hosted-provider-setup", {
|
||||
mentions: [
|
||||
"applyProviderDefaultModel",
|
||||
"discoverOpenAICompatibleLocalModels",
|
||||
"discoverOpenAICompatibleSelfHostedProvider",
|
||||
"configureOpenAICompatibleSelfHostedProviderNonInteractive",
|
||||
],
|
||||
omits: ["buildVllmProvider", "buildSglangProvider"],
|
||||
});
|
||||
expectSourceOmitsImportPattern("self-hosted-provider-setup", "./vllm.js");
|
||||
expectSourceOmitsImportPattern("self-hosted-provider-setup", "./sglang.js");
|
||||
expectSourceOmitsSnippet("agent-runtime", "./sglang.js");
|
||||
expectSourceOmitsSnippet("agent-runtime", "./vllm.js");
|
||||
expectSourceOmitsSnippet("agent-runtime", "../../extensions/");
|
||||
expectSourceOmitsSnippet("xai-model-id", "./xai.js");
|
||||
expectSourceOmitsSnippet("xai-model-id", "../../extensions/");
|
||||
expectSourceMentions("sandbox", ["registerSandboxBackend", "runPluginCommandWithTimeout"]);
|
||||
|
||||
expectSourceMentions("secret-input", [
|
||||
"buildSecretInputSchema",
|
||||
"buildOptionalSecretInputSchema",
|
||||
"normalizeSecretInputString",
|
||||
]);
|
||||
expectSourceMentions("provider-http", [
|
||||
"assertOkOrThrowHttpError",
|
||||
"normalizeBaseUrl",
|
||||
"postJsonRequest",
|
||||
"postTranscriptionRequest",
|
||||
"requireTranscriptionText",
|
||||
]);
|
||||
expectSourceOmits("speech", [
|
||||
"buildElevenLabsSpeechProvider",
|
||||
"buildMicrosoftSpeechProvider",
|
||||
"buildOpenAISpeechProvider",
|
||||
"edgeTTS",
|
||||
"elevenLabsTTS",
|
||||
"inferEdgeExtension",
|
||||
"openaiTTS",
|
||||
"OPENAI_TTS_MODELS",
|
||||
"OPENAI_TTS_VOICES",
|
||||
]);
|
||||
expectSourceOmits("media-understanding", [
|
||||
"deepgramMediaUnderstandingProvider",
|
||||
"groqMediaUnderstandingProvider",
|
||||
"assertOkOrThrowHttpError",
|
||||
"postJsonRequest",
|
||||
"postTranscriptionRequest",
|
||||
]);
|
||||
expectSourceOmits("image-generation", [
|
||||
"buildFalImageGenerationProvider",
|
||||
"buildGoogleImageGenerationProvider",
|
||||
"buildOpenAIImageGenerationProvider",
|
||||
]);
|
||||
expectSourceOmits("config-runtime", [
|
||||
"hasConfiguredSecretInput",
|
||||
"normalizeResolvedSecretInputString",
|
||||
"normalizeSecretInputString",
|
||||
]);
|
||||
expectSourceMentions("webhook-ingress", [
|
||||
"registerPluginHttpRoute",
|
||||
"resolveWebhookPath",
|
||||
"readRequestBodyWithLimit",
|
||||
"readJsonWebhookBodyOrReject",
|
||||
"requestBodyErrorToText",
|
||||
"withResolvedWebhookRequestPipeline",
|
||||
]);
|
||||
expectSourceMentions("testing", ["removeAckReactionAfterReply", "shouldAckReaction"]);
|
||||
});
|
||||
|
||||
it("keeps shared plugin-sdk types aligned", () => {
|
||||
expectTypeOf<ContractBaseProbeResult>().toMatchTypeOf<BaseProbeResult>();
|
||||
expectTypeOf<ContractBaseTokenResolution>().toMatchTypeOf<BaseTokenResolution>();
|
||||
expectTypeOf<ContractChannelAgentTool>().toMatchTypeOf<ChannelAgentTool>();
|
||||
expectTypeOf<ContractChannelAccountSnapshot>().toMatchTypeOf<ChannelAccountSnapshot>();
|
||||
expectTypeOf<ContractChannelGroupContext>().toMatchTypeOf<ChannelGroupContext>();
|
||||
expectTypeOf<ContractChannelMessageActionAdapter>().toMatchTypeOf<ChannelMessageActionAdapter>();
|
||||
expectTypeOf<ContractChannelMessageActionContext>().toMatchTypeOf<ChannelMessageActionContext>();
|
||||
expectTypeOf<ContractChannelMessageActionName>().toMatchTypeOf<ChannelMessageActionName>();
|
||||
expectTypeOf<ContractChannelMessageToolDiscovery>().toMatchTypeOf<ChannelMessageToolDiscovery>();
|
||||
expectTypeOf<ContractChannelStatusIssue>().toMatchTypeOf<ChannelStatusIssue>();
|
||||
expectTypeOf<ContractChannelThreadingContext>().toMatchTypeOf<ChannelThreadingContext>();
|
||||
expectTypeOf<ContractChannelThreadingToolContext>().toMatchTypeOf<ChannelThreadingToolContext>();
|
||||
expectTypeOf<CoreOpenClawPluginApi>().toMatchTypeOf<OpenClawPluginApi>();
|
||||
expectTypeOf<CorePluginRuntime>().toMatchTypeOf<PluginRuntime>();
|
||||
expectTypeOf<CoreChannelMessageActionContext>().toMatchTypeOf<ChannelMessageActionContext>();
|
||||
expectTypeOf<CoreOpenClawPluginApi>().toMatchTypeOf<SharedOpenClawPluginApi>();
|
||||
expectTypeOf<CorePluginRuntime>().toMatchTypeOf<SharedPluginRuntime>();
|
||||
expectTypeOf<CoreChannelMessageActionContext>().toMatchTypeOf<SharedChannelMessageActionContext>();
|
||||
});
|
||||
|
||||
it("keeps runtime entry subpaths importable", async () => {
|
||||
const [
|
||||
coreSdk,
|
||||
channelActionsSdk,
|
||||
globalSingletonSdk,
|
||||
textRuntimeSdk,
|
||||
huggingfaceSdk,
|
||||
pluginEntrySdk,
|
||||
channelLifecycleSdk,
|
||||
channelPairingSdk,
|
||||
channelReplyPipelineSdk,
|
||||
...representativeModules
|
||||
] = await Promise.all([
|
||||
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/core"),
|
||||
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-actions"),
|
||||
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/global-singleton"),
|
||||
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/text-runtime"),
|
||||
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/huggingface"),
|
||||
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/plugin-entry"),
|
||||
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-lifecycle"),
|
||||
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-pairing"),
|
||||
importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-reply-pipeline"),
|
||||
...representativeRuntimeSmokeSubpaths.map((id) =>
|
||||
importResolvedPluginSdkSubpath(`openclaw/plugin-sdk/${id}`),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(coreSdk.definePluginEntry).toBe(pluginEntrySdk.definePluginEntry);
|
||||
expect(typeof coreSdk.optionalStringEnum).toBe("function");
|
||||
expect(typeof channelActionsSdk.optionalStringEnum).toBe("function");
|
||||
expect(typeof channelActionsSdk.stringEnum).toBe("function");
|
||||
expect(typeof globalSingletonSdk.resolveGlobalMap).toBe("function");
|
||||
expect(typeof globalSingletonSdk.resolveGlobalSingleton).toBe("function");
|
||||
expect(typeof globalSingletonSdk.createScopedExpiringIdCache).toBe("function");
|
||||
expect(typeof textRuntimeSdk.createScopedExpiringIdCache).toBe("function");
|
||||
expect(typeof textRuntimeSdk.resolveGlobalMap).toBe("function");
|
||||
expect(typeof textRuntimeSdk.resolveGlobalSingleton).toBe("function");
|
||||
expect(typeof huggingfaceSdk.buildHuggingfaceProvider).toBe("function");
|
||||
expect(typeof huggingfaceSdk.discoverHuggingfaceModels).toBe("function");
|
||||
expect(Array.isArray(huggingfaceSdk.HUGGINGFACE_MODEL_CATALOG)).toBe(true);
|
||||
|
||||
expectSourceMentions("infra-runtime", ["createRuntimeOutboundDelegates"]);
|
||||
expectSourceContains("infra-runtime", "../infra/outbound/send-deps.js");
|
||||
|
||||
expect(typeof channelLifecycleSdk.createDraftStreamLoop).toBe("function");
|
||||
expect(typeof channelLifecycleSdk.createFinalizableDraftLifecycle).toBe("function");
|
||||
expect(typeof channelLifecycleSdk.runPassiveAccountLifecycle).toBe("function");
|
||||
expect(typeof channelLifecycleSdk.createRunStateMachine).toBe("function");
|
||||
expect(typeof channelLifecycleSdk.createArmableStallWatchdog).toBe("function");
|
||||
|
||||
expectSourceMentions("channel-pairing", [
|
||||
"createChannelPairingController",
|
||||
"createChannelPairingChallengeIssuer",
|
||||
"createLoggedPairingApprovalNotifier",
|
||||
"createPairingPrefixStripper",
|
||||
"createTextPairingAdapter",
|
||||
]);
|
||||
expect("createScopedPairingAccess" in channelPairingSdk).toBe(false);
|
||||
|
||||
expectSourceMentions("channel-reply-pipeline", ["createChannelReplyPipeline"]);
|
||||
expect("createTypingCallbacks" in channelReplyPipelineSdk).toBe(false);
|
||||
expect("createReplyPrefixContext" in channelReplyPipelineSdk).toBe(false);
|
||||
expect("createReplyPrefixOptions" in channelReplyPipelineSdk).toBe(false);
|
||||
|
||||
expect(pluginSdkSubpaths.length).toBeGreaterThan(representativeRuntimeSmokeSubpaths.length);
|
||||
for (const [index, id] of representativeRuntimeSmokeSubpaths.entries()) {
|
||||
const mod = representativeModules[index];
|
||||
expect(typeof mod).toBe("object");
|
||||
expect(mod, `subpath ${id} should resolve`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("exports single-provider plugin entry helpers from the dedicated subpath", () => {
|
||||
expect(typeof providerEntrySdk.defineSingleProviderPluginEntry).toBe("function");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user