refactor(plugins): decouple bundled plugin runtime loading

This commit is contained in:
Peter Steinberger
2026-03-29 09:08:06 +01:00
parent 1738d540f4
commit 8e0ab35b0e
582 changed files with 8057 additions and 22869 deletions

View File

@@ -14,7 +14,7 @@ assembly, and contract enforcement.
- `src/plugins/types.ts`
- `src/plugins/runtime/types.ts`
- `src/plugins/contracts/registry.ts`
- `src/extensions/public-artifacts.ts`
- `src/plugins/public-artifacts.ts`
## Boundary Rules

View File

@@ -1,4 +1,4 @@
import { BUNDLED_PLUGIN_METADATA } from "./bundled-plugin-metadata.js";
import { listBundledPluginMetadata } from "./bundled-plugin-metadata.js";
export type BundledPluginContractSnapshot = {
pluginId: string;
@@ -26,16 +26,17 @@ function uniqueStrings(values: readonly string[] | undefined): string[] {
}
export const BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS: readonly BundledPluginContractSnapshot[] =
BUNDLED_PLUGIN_METADATA.map(({ manifest }) => ({
pluginId: manifest.id,
cliBackendIds: uniqueStrings(manifest.cliBackends),
providerIds: uniqueStrings(manifest.providers),
speechProviderIds: uniqueStrings(manifest.contracts?.speechProviders),
mediaUnderstandingProviderIds: uniqueStrings(manifest.contracts?.mediaUnderstandingProviders),
imageGenerationProviderIds: uniqueStrings(manifest.contracts?.imageGenerationProviders),
webSearchProviderIds: uniqueStrings(manifest.contracts?.webSearchProviders),
toolNames: uniqueStrings(manifest.contracts?.tools),
}))
listBundledPluginMetadata()
.map(({ manifest }) => ({
pluginId: manifest.id,
cliBackendIds: uniqueStrings(manifest.cliBackends),
providerIds: uniqueStrings(manifest.providers),
speechProviderIds: uniqueStrings(manifest.contracts?.speechProviders),
mediaUnderstandingProviderIds: uniqueStrings(manifest.contracts?.mediaUnderstandingProviders),
imageGenerationProviderIds: uniqueStrings(manifest.contracts?.imageGenerationProviders),
webSearchProviderIds: uniqueStrings(manifest.contracts?.webSearchProviders),
toolNames: uniqueStrings(manifest.contracts?.tools),
}))
.filter(
(entry) =>
entry.cliBackendIds.length > 0 ||
@@ -100,18 +101,22 @@ export const BUNDLED_PROVIDER_PLUGIN_ID_ALIASES = Object.fromEntries(
) as Readonly<Record<string, string>>;
export const BUNDLED_LEGACY_PLUGIN_ID_ALIASES = Object.fromEntries(
BUNDLED_PLUGIN_METADATA.flatMap(({ manifest }) =>
(manifest.legacyPluginIds ?? []).map(
(legacyPluginId) => [legacyPluginId, manifest.id] as const,
),
).toSorted(([left], [right]) => left.localeCompare(right)),
listBundledPluginMetadata()
.flatMap(({ manifest }) =>
(manifest.legacyPluginIds ?? []).map(
(legacyPluginId) => [legacyPluginId, manifest.id] as const,
),
)
.toSorted(([left], [right]) => left.localeCompare(right)),
) as Readonly<Record<string, string>>;
export const BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS = Object.fromEntries(
BUNDLED_PLUGIN_METADATA.flatMap(({ manifest }) =>
(manifest.autoEnableWhenConfiguredProviders ?? []).map((providerId) => [
providerId,
manifest.id,
]),
).toSorted(([left], [right]) => left.localeCompare(right)),
listBundledPluginMetadata()
.flatMap(({ manifest }) =>
(manifest.autoEnableWhenConfiguredProviders ?? []).map((providerId) => [
providerId,
manifest.id,
]),
)
.toSorted(([left], [right]) => left.localeCompare(right)),
) as Readonly<Record<string, string>>;

View File

@@ -81,7 +81,7 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env):
// ignore
}
// bun --compile: ship a sibling `extensions/` next to the executable.
// bun --compile: ship a sibling bundled plugin tree next to the executable.
try {
const execDir = path.dirname(process.execPath);
const siblingBuilt = path.join(execDir, "dist", "extensions");
@@ -96,7 +96,7 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env):
// ignore
}
// npm/dev: walk up from this module to find `extensions/` at the package root.
// npm/dev: walk up from this module to find the bundled plugin tree at the package root.
try {
let cursor = path.dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 6; i += 1) {

View File

@@ -1,10 +0,0 @@
import { loadGeneratedBundledPluginEntries } from "../generated/bundled-plugin-entries.generated.js";
import type { OpenClawPluginDefinition } from "./types.js";
type BundledRegistrablePlugin = OpenClawPluginDefinition & {
id: string;
register: NonNullable<OpenClawPluginDefinition["register"]>;
};
export const BUNDLED_PLUGIN_ENTRIES =
(await loadGeneratedBundledPluginEntries()) as unknown as readonly BundledRegistrablePlugin[];

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,8 @@ import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
collectBundledPluginMetadata,
writeBundledPluginMetadataModule,
} from "../../scripts/generate-bundled-plugin-metadata.mjs";
import {
BUNDLED_PLUGIN_METADATA,
clearBundledPluginMetadataCache,
listBundledPluginMetadata,
resolveBundledPluginGeneratedPath,
} from "./bundled-plugin-metadata.js";
import {
@@ -53,44 +50,17 @@ function expectArtifactPresence(
}
}
async function writeGeneratedMetadataModule(params: {
repoRoot: string;
outputPath?: string;
check?: boolean;
}) {
return writeBundledPluginMetadataModule({
repoRoot: params.repoRoot,
outputPath: params.outputPath ?? "src/plugins/bundled-plugin-metadata.generated.ts",
...(params.check ? { check: true } : {}),
});
}
async function expectGeneratedMetadataModuleState(params: {
repoRoot: string;
check?: boolean;
expected: { changed?: boolean; wrote?: boolean };
}) {
const result = await writeGeneratedMetadataModule({
repoRoot: params.repoRoot,
...(params.check ? { check: true } : {}),
});
expect(result).toEqual(expect.objectContaining(params.expected));
return result;
}
describe("bundled plugin metadata", () => {
it(
"matches the generated metadata snapshot",
"matches the runtime metadata snapshot",
{ timeout: BUNDLED_PLUGIN_METADATA_TEST_TIMEOUT_MS },
async () => {
await expect(collectBundledPluginMetadata({ repoRoot })).resolves.toEqual(
BUNDLED_PLUGIN_METADATA,
);
() => {
expect(listBundledPluginMetadata({ rootDir: repoRoot })).toEqual(listBundledPluginMetadata());
},
);
it("captures setup-entry metadata for bundled channel plugins", () => {
const discord = BUNDLED_PLUGIN_METADATA.find((entry) => entry.dirName === "discord");
const discord = listBundledPluginMetadata().find((entry) => entry.dirName === "discord");
expect(discord?.source).toEqual({ source: "./index.ts", built: "index.js" });
expect(discord?.setupSource).toEqual({ source: "./setup-entry.ts", built: "setup-entry.js" });
expectArtifactPresence(discord?.publicSurfaceArtifacts, {
@@ -109,7 +79,7 @@ describe("bundled plugin metadata", () => {
});
it("excludes test-only public surface artifacts", () => {
BUNDLED_PLUGIN_METADATA.forEach((entry) =>
listBundledPluginMetadata().forEach((entry) =>
expectTestOnlyArtifactsExcluded(entry.publicSurfaceArtifacts ?? []),
);
});
@@ -125,46 +95,7 @@ describe("bundled plugin metadata", () => {
expectGeneratedPathResolution(tempRoot, path.join("plugin", "index.js"));
});
it("supports check mode for stale generated artifacts", async () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-generated-");
writeJson(path.join(tempRoot, "extensions", "alpha", "package.json"), {
name: "@openclaw/alpha",
version: "0.0.1",
openclaw: {
extensions: ["./index.ts"],
},
});
writeJson(path.join(tempRoot, "extensions", "alpha", "openclaw.plugin.json"), {
id: "alpha",
configSchema: { type: "object" },
});
await expectGeneratedMetadataModuleState({
repoRoot: tempRoot,
expected: { wrote: true },
});
await expectGeneratedMetadataModuleState({
repoRoot: tempRoot,
check: true,
expected: { changed: false, wrote: false },
});
fs.writeFileSync(
path.join(tempRoot, "src/plugins/bundled-plugin-metadata.generated.ts"),
"// stale\n",
"utf8",
);
await expectGeneratedMetadataModuleState({
repoRoot: tempRoot,
check: true,
expected: { changed: true, wrote: false },
});
});
it("merges generated channel schema metadata with manifest-owned channel config fields", async () => {
it("merges runtime channel schema metadata with manifest-owned channel config fields", () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-channel-configs-");
writeJson(path.join(tempRoot, "extensions", "alpha", "package.json"), {
@@ -219,7 +150,8 @@ describe("bundled plugin metadata", () => {
"utf8",
);
const entries = await collectBundledPluginMetadata({ repoRoot: tempRoot });
clearBundledPluginMetadataCache();
const entries = listBundledPluginMetadata({ rootDir: tempRoot });
const channelConfigs = entries[0]?.manifest.channelConfigs as
| Record<string, unknown>
| undefined;
@@ -240,7 +172,7 @@ describe("bundled plugin metadata", () => {
});
});
it("captures top-level public surface artifacts without duplicating the primary entrypoints", async () => {
it("captures top-level public surface artifacts without duplicating the primary entrypoints", () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-public-artifacts-");
writeJson(path.join(tempRoot, "extensions", "alpha", "package.json"), {
@@ -272,7 +204,8 @@ describe("bundled plugin metadata", () => {
"utf8",
);
const entries = await collectBundledPluginMetadata({ repoRoot: tempRoot });
clearBundledPluginMetadataCache();
const entries = listBundledPluginMetadata({ rootDir: tempRoot });
const firstEntry = entries[0] as
| {
publicSurfaceArtifacts?: string[];
@@ -282,4 +215,77 @@ describe("bundled plugin metadata", () => {
expect(firstEntry?.publicSurfaceArtifacts).toEqual(["api.js", "runtime-api.js"]);
expect(firstEntry?.runtimeSidecarArtifacts).toEqual(["runtime-api.js"]);
});
it("loads channel config metadata from built public surfaces in dist-only roots", () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-dist-config-");
const distRoot = path.join(tempRoot, "dist");
writeJson(path.join(distRoot, "extensions", "alpha", "package.json"), {
name: "@openclaw/alpha",
version: "0.0.1",
openclaw: {
extensions: ["./index.ts"],
channel: {
id: "alpha",
label: "Alpha Root Label",
blurb: "Alpha Root Description",
},
},
});
writeJson(path.join(distRoot, "extensions", "alpha", "openclaw.plugin.json"), {
id: "alpha",
channels: ["alpha"],
channelConfigs: {
alpha: {
schema: { type: "object", properties: { stale: { type: "boolean" } } },
uiHints: {
"channels.alpha.explicitOnly": {
help: "manifest hint",
},
},
},
},
});
fs.writeFileSync(
path.join(distRoot, "extensions", "alpha", "index.js"),
"export {};\n",
"utf8",
);
fs.writeFileSync(
path.join(distRoot, "extensions", "alpha", "channel-config-api.js"),
[
"export const AlphaChannelConfigSchema = {",
" schema: {",
" type: 'object',",
" properties: { built: { type: 'string' } },",
" },",
" uiHints: {",
" 'channels.alpha.generatedOnly': { help: 'built hint' },",
" },",
"};",
"",
].join("\n"),
"utf8",
);
clearBundledPluginMetadataCache();
const entries = listBundledPluginMetadata({ rootDir: distRoot });
const channelConfigs = entries[0]?.manifest.channelConfigs as
| Record<string, unknown>
| undefined;
expect(channelConfigs?.alpha).toEqual({
schema: {
type: "object",
properties: {
built: { type: "string" },
},
},
label: "Alpha Root Label",
description: "Alpha Root Description",
uiHints: {
"channels.alpha.generatedOnly": { help: "built hint" },
"channels.alpha.explicitOnly": { help: "manifest hint" },
},
});
});
});

View File

@@ -1,20 +1,57 @@
import fs from "node:fs";
import path from "node:path";
import { GENERATED_BUNDLED_PLUGIN_METADATA } from "./bundled-plugin-metadata.generated.js";
import type { PluginManifest, OpenClawPackageManifest } from "./manifest.js";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import {
getPackageManifestMetadata,
loadPluginManifest,
type OpenClawPackageManifest,
type PackageManifest,
type PluginManifest,
type PluginManifestChannelConfig,
} from "./manifest.js";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
resolveLoaderPackageRoot,
shouldPreferNativeJiti,
} from "./sdk-alias.js";
import type { PluginConfigUiHint } from "./types.js";
const OPENCLAW_PACKAGE_ROOT =
resolveLoaderPackageRoot({
modulePath: fileURLToPath(import.meta.url),
moduleUrl: import.meta.url,
}) ?? fileURLToPath(new URL("../..", import.meta.url));
const PUBLIC_SURFACE_SOURCE_EXTENSIONS = [".ts", ".mts", ".js", ".mjs", ".cts", ".cjs"] as const;
const RUNTIME_SIDECAR_ARTIFACTS = new Set([
"helper-api.js",
"light-runtime-api.js",
"runtime-api.js",
"thread-bindings-runtime.js",
]);
const SOURCE_CONFIG_SCHEMA_CANDIDATES = [
path.join("src", "config-schema.ts"),
path.join("src", "config-schema.js"),
path.join("src", "config-schema.mts"),
path.join("src", "config-schema.mjs"),
path.join("src", "config-schema.cts"),
path.join("src", "config-schema.cjs"),
] as const;
const PUBLIC_CONFIG_SURFACE_BASENAMES = ["channel-config-api", "runtime-api", "api"] as const;
type GeneratedBundledPluginPathPair = {
type BundledPluginPathPair = {
source: string;
built: string;
};
export type GeneratedBundledPluginMetadata = {
export type BundledPluginMetadata = {
dirName: string;
idHint: string;
source: GeneratedBundledPluginPathPair;
setupSource?: GeneratedBundledPluginPathPair;
source: BundledPluginPathPair;
setupSource?: BundledPluginPathPair;
publicSurfaceArtifacts?: readonly string[];
runtimeSidecarArtifacts?: readonly string[];
packageName?: string;
@@ -24,12 +61,409 @@ export type GeneratedBundledPluginMetadata = {
manifest: PluginManifest;
};
export const BUNDLED_PLUGIN_METADATA =
GENERATED_BUNDLED_PLUGIN_METADATA as unknown as readonly GeneratedBundledPluginMetadata[];
type ChannelConfigSurface = {
schema: Record<string, unknown>;
uiHints?: Record<string, PluginConfigUiHint>;
};
const bundledPluginMetadataCache = new Map<string, readonly BundledPluginMetadata[]>();
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
export function clearBundledPluginMetadataCache(): void {
bundledPluginMetadataCache.clear();
}
function trimString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => trimString(entry) ?? "").filter(Boolean);
}
function rewriteEntryToBuiltPath(entry: string | undefined): string | undefined {
if (!entry) {
return undefined;
}
const normalized = entry.replace(/^\.\//u, "");
return normalized.replace(/\.[^.]+$/u, ".js");
}
function readPackageManifest(pluginDir: string): PackageManifest | undefined {
const packagePath = path.join(pluginDir, "package.json");
if (!fs.existsSync(packagePath)) {
return undefined;
}
try {
return JSON.parse(fs.readFileSync(packagePath, "utf-8")) as PackageManifest;
} catch {
return undefined;
}
}
function deriveIdHint(params: {
entryPath: string;
manifestId: string;
packageName?: string;
hasMultipleExtensions: boolean;
}): string {
const base = path.basename(params.entryPath, path.extname(params.entryPath));
if (!params.hasMultipleExtensions) {
return params.manifestId;
}
const packageName = trimString(params.packageName);
if (!packageName) {
return `${params.manifestId}/${base}`;
}
const unscoped = packageName.includes("/")
? (packageName.split("/").pop() ?? packageName)
: packageName;
return `${unscoped}/${base}`;
}
function isTopLevelPublicSurfaceSource(name: string): boolean {
if (
!PUBLIC_SURFACE_SOURCE_EXTENSIONS.includes(
path.extname(name) as (typeof PUBLIC_SURFACE_SOURCE_EXTENSIONS)[number],
)
) {
return false;
}
if (name.startsWith(".")) {
return false;
}
if (name.startsWith("test-")) {
return false;
}
if (name.includes(".test-")) {
return false;
}
if (name.endsWith(".d.ts")) {
return false;
}
return !/(\.test|\.spec)(\.[cm]?[jt]s)$/u.test(name);
}
function collectTopLevelPublicSurfaceArtifacts(params: {
pluginDir: string;
sourceEntry: string;
setupEntry?: string;
}): readonly string[] | undefined {
const excluded = new Set(
[params.sourceEntry, params.setupEntry]
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
.map((entry) => path.basename(entry)),
);
const artifacts = fs
.readdirSync(params.pluginDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter(isTopLevelPublicSurfaceSource)
.filter((entry) => !excluded.has(entry))
.map((entry) => rewriteEntryToBuiltPath(entry))
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.toSorted((left, right) => left.localeCompare(right));
return artifacts.length > 0 ? artifacts : undefined;
}
function collectRuntimeSidecarArtifacts(
publicSurfaceArtifacts: readonly string[] | undefined,
): readonly string[] | undefined {
if (!publicSurfaceArtifacts) {
return undefined;
}
const artifacts = publicSurfaceArtifacts.filter((artifact) =>
RUNTIME_SIDECAR_ARTIFACTS.has(artifact),
);
return artifacts.length > 0 ? artifacts : undefined;
}
function resolveBundledPluginScanDir(packageRoot: string): string | undefined {
const sourceDir = path.join(packageRoot, "extensions");
const runtimeDir = path.join(packageRoot, "dist-runtime", "extensions");
const builtDir = path.join(packageRoot, "dist", "extensions");
if (fs.existsSync(sourceDir)) {
return sourceDir;
}
if (fs.existsSync(runtimeDir) && fs.existsSync(builtDir)) {
return runtimeDir;
}
if (fs.existsSync(builtDir)) {
return builtDir;
}
return undefined;
}
function isBuiltChannelConfigSchema(value: unknown): value is ChannelConfigSurface {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as { schema?: unknown };
return Boolean(candidate.schema && typeof candidate.schema === "object");
}
function resolveConfigSchemaExport(imported: Record<string, unknown>): ChannelConfigSurface | null {
for (const [name, value] of Object.entries(imported)) {
if (name.endsWith("ChannelConfigSchema") && isBuiltChannelConfigSchema(value)) {
return value;
}
}
for (const [name, value] of Object.entries(imported)) {
if (!name.endsWith("ConfigSchema") || name.endsWith("AccountConfigSchema")) {
continue;
}
if (isBuiltChannelConfigSchema(value)) {
return value;
}
if (value && typeof value === "object") {
return buildChannelConfigSchema(value as never);
}
}
for (const value of Object.values(imported)) {
if (isBuiltChannelConfigSchema(value)) {
return value;
}
}
return null;
}
function getJiti(modulePath: string) {
const tryNative =
shouldPreferNativeJiti(modulePath) || modulePath.includes(`${path.sep}dist${path.sep}`);
const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url);
const cacheKey = JSON.stringify({
tryNative,
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
});
const cached = jitiLoaders.get(cacheKey);
if (cached) {
return cached;
}
const loader = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative,
});
jitiLoaders.set(cacheKey, loader);
return loader;
}
function resolveChannelConfigSchemaModulePath(pluginDir: string): string | undefined {
for (const relativePath of SOURCE_CONFIG_SCHEMA_CANDIDATES) {
const candidate = path.join(pluginDir, relativePath);
if (fs.existsSync(candidate)) {
return candidate;
}
}
for (const basename of PUBLIC_CONFIG_SURFACE_BASENAMES) {
for (const extension of PUBLIC_SURFACE_SOURCE_EXTENSIONS) {
const candidate = path.join(pluginDir, `${basename}${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
return undefined;
}
function loadChannelConfigSurfaceModuleSync(modulePath: string): ChannelConfigSurface | null {
try {
const imported = getJiti(modulePath)(modulePath) as Record<string, unknown>;
return resolveConfigSchemaExport(imported);
} catch {
return null;
}
}
function resolvePackageChannelMeta(
packageManifest: OpenClawPackageManifest | undefined,
channelId: string,
): OpenClawPackageManifest["channel"] | undefined {
const channelMeta = packageManifest?.channel;
return channelMeta?.id?.trim() === channelId ? channelMeta : undefined;
}
function collectBundledChannelConfigs(params: {
pluginDir: string;
manifest: PluginManifest;
packageManifest?: OpenClawPackageManifest;
}): Record<string, PluginManifestChannelConfig> | undefined {
const channelIds = normalizeStringList(params.manifest.channels);
const existingChannelConfigs: Record<string, PluginManifestChannelConfig> =
params.manifest.channelConfigs && Object.keys(params.manifest.channelConfigs).length > 0
? { ...params.manifest.channelConfigs }
: {};
if (channelIds.length === 0) {
return Object.keys(existingChannelConfigs).length > 0 ? existingChannelConfigs : undefined;
}
const surfaceModulePath = resolveChannelConfigSchemaModulePath(params.pluginDir);
const surface = surfaceModulePath ? loadChannelConfigSurfaceModuleSync(surfaceModulePath) : null;
for (const channelId of channelIds) {
const existing = existingChannelConfigs[channelId];
const channelMeta = resolvePackageChannelMeta(params.packageManifest, channelId);
const preferOver = normalizeStringList(channelMeta?.preferOver);
const uiHints: Record<string, PluginConfigUiHint> | undefined =
surface?.uiHints || existing?.uiHints
? {
...(surface?.uiHints && Object.keys(surface.uiHints).length > 0 ? surface.uiHints : {}),
...(existing?.uiHints && Object.keys(existing.uiHints).length > 0
? existing.uiHints
: {}),
}
: undefined;
if (!surface?.schema && !existing?.schema) {
continue;
}
existingChannelConfigs[channelId] = {
schema: surface?.schema ?? existing?.schema ?? {},
...(uiHints && Object.keys(uiHints).length > 0 ? { uiHints } : {}),
...((trimString(existing?.label) ?? trimString(channelMeta?.label))
? { label: trimString(existing?.label) ?? trimString(channelMeta?.label)! }
: {}),
...((trimString(existing?.description) ?? trimString(channelMeta?.blurb))
? {
description: trimString(existing?.description) ?? trimString(channelMeta?.blurb)!,
}
: {}),
...(existing?.preferOver?.length
? { preferOver: existing.preferOver }
: preferOver.length > 0
? { preferOver }
: {}),
};
}
return Object.keys(existingChannelConfigs).length > 0 ? existingChannelConfigs : undefined;
}
function collectBundledPluginMetadataForPackageRoot(
packageRoot: string,
): readonly BundledPluginMetadata[] {
const scanDir = resolveBundledPluginScanDir(packageRoot);
if (!scanDir || !fs.existsSync(scanDir)) {
return [];
}
const entries: BundledPluginMetadata[] = [];
for (const dirName of fs
.readdirSync(scanDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.toSorted((left, right) => left.localeCompare(right))) {
const pluginDir = path.join(scanDir, dirName);
const manifestResult = loadPluginManifest(pluginDir, false);
if (!manifestResult.ok) {
continue;
}
const packageJson = readPackageManifest(pluginDir);
const packageManifest = getPackageManifestMetadata(packageJson);
const extensions = normalizeStringList(packageManifest?.extensions);
if (extensions.length === 0) {
continue;
}
const sourceEntry = trimString(extensions[0]);
const builtEntry = rewriteEntryToBuiltPath(sourceEntry);
if (!sourceEntry || !builtEntry) {
continue;
}
const setupSourcePath = trimString(packageManifest?.setupEntry);
const setupSource =
setupSourcePath && rewriteEntryToBuiltPath(setupSourcePath)
? {
source: setupSourcePath,
built: rewriteEntryToBuiltPath(setupSourcePath)!,
}
: undefined;
const publicSurfaceArtifacts = collectTopLevelPublicSurfaceArtifacts({
pluginDir,
sourceEntry,
...(setupSourcePath ? { setupEntry: setupSourcePath } : {}),
});
const runtimeSidecarArtifacts = collectRuntimeSidecarArtifacts(publicSurfaceArtifacts);
const channelConfigs = collectBundledChannelConfigs({
pluginDir,
manifest: manifestResult.manifest,
packageManifest,
});
entries.push({
dirName,
idHint: deriveIdHint({
entryPath: sourceEntry,
manifestId: manifestResult.manifest.id,
packageName: trimString(packageJson?.name),
hasMultipleExtensions: extensions.length > 1,
}),
source: {
source: sourceEntry,
built: builtEntry,
},
...(setupSource ? { setupSource } : {}),
...(publicSurfaceArtifacts ? { publicSurfaceArtifacts } : {}),
...(runtimeSidecarArtifacts ? { runtimeSidecarArtifacts } : {}),
...(trimString(packageJson?.name) ? { packageName: trimString(packageJson?.name) } : {}),
...(trimString(packageJson?.version)
? { packageVersion: trimString(packageJson?.version) }
: {}),
...(trimString(packageJson?.description)
? { packageDescription: trimString(packageJson?.description) }
: {}),
...(packageManifest ? { packageManifest } : {}),
manifest: {
...manifestResult.manifest,
...(channelConfigs ? { channelConfigs } : {}),
},
});
}
return entries;
}
export function listBundledPluginMetadata(params?: {
rootDir?: string;
}): readonly BundledPluginMetadata[] {
const rootDir = path.resolve(params?.rootDir ?? OPENCLAW_PACKAGE_ROOT);
const cached = bundledPluginMetadataCache.get(rootDir);
if (cached) {
return cached;
}
const entries = Object.freeze(collectBundledPluginMetadataForPackageRoot(rootDir));
bundledPluginMetadataCache.set(rootDir, entries);
return entries;
}
export function findBundledPluginMetadataById(
pluginId: string,
params?: { rootDir?: string },
): BundledPluginMetadata | undefined {
return listBundledPluginMetadata(params).find((entry) => entry.manifest.id === pluginId);
}
export function resolveBundledPluginWorkspaceSourcePath(params: {
rootDir: string;
pluginId: string;
}): string | null {
const metadata = findBundledPluginMetadataById(params.pluginId, { rootDir: params.rootDir });
if (!metadata) {
return null;
}
return path.resolve(params.rootDir, "extensions", metadata.dirName);
}
export function resolveBundledPluginGeneratedPath(
rootDir: string,
entry: GeneratedBundledPluginPathPair | undefined,
entry: BundledPluginPathPair | undefined,
): string | null {
if (!entry) {
return null;
@@ -51,21 +485,39 @@ export function resolveBundledPluginPublicSurfacePath(params: {
rootDir: string;
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
bundledPluginsDir?: string;
}): string | null {
const artifactBasename = params.artifactBasename.replace(/^\.\//u, "");
if (!artifactBasename) {
return null;
}
const builtCandidate = path.resolve(
params.rootDir,
"dist",
"extensions",
params.dirName,
artifactBasename,
);
if (fs.existsSync(builtCandidate)) {
return builtCandidate;
const explicitBundledPluginsDir =
params.bundledPluginsDir ?? resolveBundledPluginsDir(params.env ?? process.env);
if (explicitBundledPluginsDir) {
const explicitPluginDir = path.resolve(explicitBundledPluginsDir, params.dirName);
const explicitBuiltCandidate = path.join(explicitPluginDir, artifactBasename);
if (fs.existsSync(explicitBuiltCandidate)) {
return explicitBuiltCandidate;
}
const sourceBaseName = artifactBasename.replace(/\.js$/u, "");
for (const ext of PUBLIC_SURFACE_SOURCE_EXTENSIONS) {
const sourceCandidate = path.join(explicitPluginDir, `${sourceBaseName}${ext}`);
if (fs.existsSync(sourceCandidate)) {
return sourceCandidate;
}
}
}
for (const candidate of [
path.resolve(params.rootDir, "dist", "extensions", params.dirName, artifactBasename),
path.resolve(params.rootDir, "dist-runtime", "extensions", params.dirName, artifactBasename),
]) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
const sourceBaseName = artifactBasename.replace(/\.js$/u, "");

View File

@@ -1,10 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { bundledPluginRootAt } from "../../test/helpers/bundled-plugin-paths.js";
import {
findBundledPluginSource,
findBundledPluginSourceInMap,
resolveBundledPluginSources,
} from "./bundled-sources.js";
const APP_ROOT = "/app";
function appBundledPluginRoot(pluginId: string): string {
return bundledPluginRootAt(APP_ROOT, pluginId);
}
const discoverOpenClawPluginsMock = vi.fn();
const loadPluginManifestMock = vi.fn();
@@ -56,17 +63,17 @@ function setBundledManifestIdsByRoot(manifestIds: Record<string, string>) {
function setBundledLookupFixture() {
setBundledDiscoveryCandidates([
createBundledCandidate({
rootDir: "/app/extensions/feishu",
rootDir: appBundledPluginRoot("feishu"),
packageName: "@openclaw/feishu",
}),
createBundledCandidate({
rootDir: "/app/extensions/diffs",
rootDir: appBundledPluginRoot("diffs"),
packageName: "@openclaw/diffs",
}),
]);
setBundledManifestIdsByRoot({
"/app/extensions/feishu": "feishu",
"/app/extensions/diffs": "diffs",
[appBundledPluginRoot("feishu")]: "feishu",
[appBundledPluginRoot("diffs")]: "diffs",
});
}
@@ -127,21 +134,21 @@ describe("bundled plugin sources", () => {
packageName: "@openclaw/feishu",
}),
createBundledCandidate({
rootDir: "/app/extensions/feishu",
rootDir: appBundledPluginRoot("feishu"),
packageName: "@openclaw/feishu",
}),
createBundledCandidate({
rootDir: "/app/extensions/feishu-dup",
rootDir: appBundledPluginRoot("feishu-dup"),
packageName: "@openclaw/feishu",
}),
createBundledCandidate({
rootDir: "/app/extensions/msteams",
rootDir: appBundledPluginRoot("msteams"),
packageName: "@openclaw/msteams",
}),
]);
setBundledManifestIdsByRoot({
"/app/extensions/feishu": "feishu",
"/app/extensions/msteams": "msteams",
[appBundledPluginRoot("feishu")]: "feishu",
[appBundledPluginRoot("msteams")]: "msteams",
});
const map = resolveBundledPluginSources({});
@@ -150,7 +157,7 @@ describe("bundled plugin sources", () => {
expect(map.get("feishu")).toEqual(
createResolvedBundledSource({
pluginId: "feishu",
localPath: "/app/extensions/feishu",
localPath: appBundledPluginRoot("feishu"),
}),
);
});
@@ -159,7 +166,7 @@ describe("bundled plugin sources", () => {
[
"finds bundled source by npm spec",
{ kind: "npmSpec", value: "@openclaw/feishu" } as const,
{ pluginId: "feishu", localPath: "/app/extensions/feishu" },
{ pluginId: "feishu", localPath: appBundledPluginRoot("feishu") },
],
[
"returns undefined for missing npm spec",
@@ -169,7 +176,7 @@ describe("bundled plugin sources", () => {
[
"finds bundled source by plugin id",
{ kind: "pluginId", value: "diffs" } as const,
{ pluginId: "diffs", localPath: "/app/extensions/diffs" },
{ pluginId: "diffs", localPath: appBundledPluginRoot("diffs") },
],
[
"returns undefined for missing plugin id",
@@ -211,7 +218,7 @@ describe("bundled plugin sources", () => {
"feishu",
createResolvedBundledSource({
pluginId: "feishu",
localPath: "/app/extensions/feishu",
localPath: appBundledPluginRoot("feishu"),
}),
],
]);
@@ -224,7 +231,7 @@ describe("bundled plugin sources", () => {
).toEqual(
createResolvedBundledSource({
pluginId: "feishu",
localPath: "/app/extensions/feishu",
localPath: appBundledPluginRoot("feishu"),
}),
);
expect(

View File

@@ -84,3 +84,20 @@ export function findBundledPluginSource(params: {
lookup: params.lookup,
});
}
export function resolveBundledPluginInstallCommandHint(params: {
pluginId: string;
workspaceDir?: string;
/** Use an explicit env when bundled roots should resolve independently from process.env. */
env?: NodeJS.ProcessEnv;
}): string | null {
const bundledSource = findBundledPluginSource({
lookup: { kind: "pluginId", value: params.pluginId },
workspaceDir: params.workspaceDir,
env: params.env,
});
if (!bundledSource?.localPath) {
return null;
}
return `openclaw plugins install ${bundledSource.localPath}`;
}

View File

@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import {
loadBundledPluginPublicSurfaceSync,
resolveRelativeBundledPluginPublicModuleId,
} from "../../test-utils/bundled-plugin-public-surface.js";
import { registerProviders, requireProvider } from "./testkit.js";
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
@@ -136,6 +140,21 @@ function runCatalog(params: {
describe("provider discovery contract", () => {
beforeEach(async () => {
const githubCopilotTokenModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "github-copilot",
artifactBasename: "token.js",
});
const vllmApiModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "vllm",
artifactBasename: "api.js",
});
const sglangApiModuleId = resolveRelativeBundledPluginPublicModuleId({
fromModuleUrl: import.meta.url,
pluginId: "sglang",
artifactBasename: "api.js",
});
vi.resetModules();
vi.doMock("openclaw/plugin-sdk/agent-runtime", async () => {
// Import the direct source module, not the mocked subpath, so bundled
@@ -155,8 +174,8 @@ describe("provider discovery contract", () => {
listProfilesForProvider: listProfilesForProviderMock,
};
});
vi.doMock("../../../extensions/github-copilot/token.js", async () => {
const actual = await vi.importActual<object>("../../../extensions/github-copilot/token.js");
vi.doMock(githubCopilotTokenModuleId, async () => {
const actual = await vi.importActual<object>(githubCopilotTokenModuleId);
return {
...actual,
resolveCopilotApiToken: resolveCopilotApiTokenMock,
@@ -181,15 +200,15 @@ describe("provider discovery contract", () => {
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
};
});
vi.doMock("../../../extensions/vllm/api.js", async () => {
const actual = await vi.importActual<object>("../../../extensions/vllm/api.js");
vi.doMock(vllmApiModuleId, async () => {
const actual = await vi.importActual<object>(vllmApiModuleId);
return {
...actual,
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
};
});
vi.doMock("../../../extensions/sglang/api.js", async () => {
const actual = await vi.importActual<object>("../../../extensions/sglang/api.js");
vi.doMock(sglangApiModuleId, async () => {
const actual = await vi.importActual<object>(sglangApiModuleId);
return {
...actual,
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
@@ -205,13 +224,27 @@ describe("provider discovery contract", () => {
{ default: modelStudioPlugin },
{ default: cloudflareAiGatewayPlugin },
] = await Promise.all([
import("../../../extensions/github-copilot/index.js"),
import("../../../extensions/ollama/index.js"),
import("../../../extensions/vllm/index.js"),
import("../../../extensions/sglang/index.js"),
import("../../../extensions/minimax/index.js"),
import("../../../extensions/modelstudio/index.js"),
import("../../../extensions/cloudflare-ai-gateway/index.js"),
loadBundledPluginPublicSurfaceSync<{
default: Parameters<typeof registerProviders>[0];
}>({ pluginId: "github-copilot", artifactBasename: "index.js" }),
loadBundledPluginPublicSurfaceSync<{
default: Parameters<typeof registerProviders>[0];
}>({ pluginId: "ollama", artifactBasename: "index.js" }),
loadBundledPluginPublicSurfaceSync<{
default: Parameters<typeof registerProviders>[0];
}>({ pluginId: "vllm", artifactBasename: "index.js" }),
loadBundledPluginPublicSurfaceSync<{
default: Parameters<typeof registerProviders>[0];
}>({ pluginId: "sglang", artifactBasename: "index.js" }),
loadBundledPluginPublicSurfaceSync<{
default: Parameters<typeof registerProviders>[0];
}>({ pluginId: "minimax", artifactBasename: "index.js" }),
loadBundledPluginPublicSurfaceSync<{
default: Parameters<typeof registerProviders>[0];
}>({ pluginId: "modelstudio", artifactBasename: "index.js" }),
loadBundledPluginPublicSurfaceSync<{
default: Parameters<typeof registerProviders>[0];
}>({ pluginId: "cloudflare-ai-gateway", artifactBasename: "index.js" }),
]);
githubCopilotProvider = requireProvider(
registerProviders(githubCopilotPlugin),

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { bundledDistPluginFile } from "../../test/helpers/bundled-plugin-paths.js";
import { clearPluginDiscoveryCache, discoverOpenClawPlugins } from "./discovery.js";
import {
cleanupTrackedTempDirs,
@@ -371,7 +372,7 @@ describe("discoverOpenClawPlugins", () => {
writePluginPackageManifest({
packageDir: path.join(pluginDir, "node_modules", "openclaw"),
packageName: "openclaw",
extensions: ["./dist/extensions/diffs/index.js"],
extensions: [`./${bundledDistPluginFile("diffs", "index.js")}`],
});
writePluginManifest({ pluginDir: nestedDiffsDir, id: "diffs" });
fs.writeFileSync(

View File

@@ -3,10 +3,6 @@ import path from "node:path";
import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { resolveUserPath } from "../utils.js";
import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js";
import {
BUNDLED_PLUGIN_METADATA,
resolveBundledPluginGeneratedPath,
} from "./bundled-plugin-metadata.js";
import {
DEFAULT_PLUGIN_ENTRY_CANDIDATES,
getPackageManifestMetadata,
@@ -471,11 +467,56 @@ function resolvePackageEntrySource(params: {
rejectHardlinks?: boolean;
}): string | null {
const source = path.resolve(params.packageDir, params.entryPath);
const rejectHardlinks = params.rejectHardlinks ?? true;
const candidates = [source];
if (!rejectHardlinks) {
const builtCandidate = source.replace(/\.[^.]+$/u, ".js");
if (builtCandidate !== source) {
candidates.push(builtCandidate);
}
}
for (const candidate of new Set(candidates)) {
if (!fs.existsSync(candidate)) {
continue;
}
const opened = openBoundaryFileSync({
absolutePath: candidate,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
rejectHardlinks,
});
if (!opened.ok) {
return matchBoundaryFileOpenFailure(opened, {
path: () => null,
io: () => {
params.diagnostics.push({
level: "warn",
message: `extension entry unreadable (I/O error): ${params.entryPath}`,
source: params.sourceLabel,
});
return null;
},
fallback: () => {
params.diagnostics.push({
level: "error",
message: `extension entry escapes package directory: ${params.entryPath}`,
source: params.sourceLabel,
});
return null;
},
});
}
const safeSource = opened.path;
fs.closeSync(opened.fd);
return safeSource;
}
const opened = openBoundaryFileSync({
absolutePath: source,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
rejectHardlinks: params.rejectHardlinks ?? true,
rejectHardlinks,
});
if (!opened.ok) {
return matchBoundaryFileOpenFailure(opened, {
@@ -788,62 +829,6 @@ function discoverFromPath(params: {
}
}
function discoverBundledMetadataInDirectory(params: {
dir: string;
ownershipUid?: number | null;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
}) {
if (!fs.existsSync(params.dir)) {
return null;
}
const coveredDirectories = new Set<string>();
for (const entry of BUNDLED_PLUGIN_METADATA) {
const rootDir = path.join(params.dir, entry.dirName);
if (!fs.existsSync(rootDir)) {
continue;
}
coveredDirectories.add(entry.dirName);
const source = resolveBundledPluginGeneratedPath(rootDir, entry.source);
if (!source) {
continue;
}
const setupSource = resolveBundledPluginGeneratedPath(rootDir, entry.setupSource);
const packageManifest = readPackageManifest(rootDir, false);
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: entry.idHint,
source,
...(setupSource ? { setupSource } : {}),
rootDir,
origin: "bundled",
ownershipUid: params.ownershipUid,
manifest: {
...packageManifest,
...(!packageManifest?.name && entry.packageName ? { name: entry.packageName } : {}),
...(!packageManifest?.version && entry.packageVersion
? { version: entry.packageVersion }
: {}),
...(!packageManifest?.description && entry.packageDescription
? { description: entry.packageDescription }
: {}),
...(!packageManifest?.openclaw && entry.packageManifest
? { openclaw: entry.packageManifest }
: {}),
},
packageDir: rootDir,
bundledManifest: entry.manifest,
bundledManifestPath: path.join(rootDir, "openclaw.plugin.json"),
});
}
return coveredDirectories;
}
export function discoverOpenClawPlugins(params: {
workspaceDir?: string;
extraPaths?: string[];
@@ -906,13 +891,6 @@ export function discoverOpenClawPlugins(params: {
}
if (roots.stock) {
const coveredBundledDirectories = discoverBundledMetadataInDirectory({
dir: roots.stock,
ownershipUid: params.ownershipUid,
candidates,
diagnostics,
seen,
});
discoverInDirectory({
dir: roots.stock,
origin: "bundled",
@@ -920,7 +898,6 @@ export function discoverOpenClawPlugins(params: {
candidates,
diagnostics,
seen,
...(coveredBundledDirectories ? { skipDirectories: coveredBundledDirectories } : {}),
});
}

View File

@@ -830,7 +830,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const getJiti = (modulePath: string) => {
const tryNative = shouldPreferNativeJiti(modulePath);
// Pass loader's moduleUrl so the openclaw root can always be resolved even when
// loading external plugins from outside the installation directory (e.g. ~/.openclaw/extensions/).
// loading external plugins from outside the managed install directory.
const aliasMap = buildPluginLoaderAliasMap(
modulePath,
process.argv[1],

View File

@@ -1,4 +1,4 @@
import { BUNDLED_PLUGIN_METADATA } from "./bundled-plugin-metadata.js";
import { listBundledPluginMetadata } from "./bundled-plugin-metadata.js";
function assertUniqueValues<T extends string>(values: readonly T[], label: string): readonly T[] {
const seen = new Set<string>();
@@ -20,12 +20,18 @@ export function getPublicArtifactBasename(relativePath: string): string {
return relativePath.split("/").at(-1) ?? relativePath;
}
function buildBundledDistArtifactPath(dirName: string, artifact: string): string {
return ["dist", "extensions", dirName, artifact].join("/");
}
export const BUNDLED_RUNTIME_SIDECAR_PATHS = assertUniqueValues(
BUNDLED_PLUGIN_METADATA.flatMap((entry) =>
(entry.runtimeSidecarArtifacts ?? []).map(
(artifact) => `dist/extensions/${entry.dirName}/${artifact}`,
),
).toSorted((left, right) => left.localeCompare(right)),
listBundledPluginMetadata()
.flatMap((entry) =>
(entry.runtimeSidecarArtifacts ?? []).map((artifact) =>
buildBundledDistArtifactPath(entry.dirName, artifact),
),
)
.toSorted((left, right) => left.localeCompare(right)),
"bundled runtime sidecar path",
);

View File

@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { bundledPluginFile } from "../../test/helpers/bundled-plugin-paths.js";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
@@ -12,7 +13,7 @@ const LIVE_RUNTIME_STATE_GUARDS: Record<
forbidden: readonly string[];
}
> = {
"extensions/whatsapp/src/active-listener.ts": {
[bundledPluginFile("whatsapp", "src/active-listener.ts")]: {
required: ["globalThis", 'Symbol.for("openclaw.whatsapp.activeListenerState")'],
forbidden: ["resolveGlobalSingleton"],
},

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs";
import { bundledDistPluginFile } from "../../test/helpers/bundled-plugin-paths.js";
import { loadPluginBoundaryModuleWithJiti } from "./runtime/runtime-plugin-boundary.js";
type LightModule = {
@@ -44,10 +45,10 @@ function createBundledWhatsAppRuntimeFixture() {
2,
),
"openclaw.mjs": "export {};\n",
"dist/extensions/whatsapp/index.js": "export default {};\n",
"dist/extensions/whatsapp/light-runtime-api.js":
[bundledDistPluginFile("whatsapp", "index.js")]: "export default {};\n",
[bundledDistPluginFile("whatsapp", "light-runtime-api.js")]:
'export { getActiveWebListener } from "../../active-listener.js";\n',
"dist/extensions/whatsapp/runtime-api.js":
[bundledDistPluginFile("whatsapp", "runtime-api.js")]:
'export { getActiveWebListener, setActiveWebListener } from "../../active-listener.js";\n',
"dist/active-listener.js": [
'const key = Symbol.for("openclaw.whatsapp.activeListenerState");',

View File

@@ -1,8 +1,4 @@
import { resolveStateDir } from "../../config/paths.js";
import {
listRuntimeImageGenerationProviders,
generateImage,
} from "../../plugin-sdk/image-generation-runtime.js";
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
import {
createLazyRuntimeMethod,
@@ -11,6 +7,7 @@ import {
} from "../../shared/lazy-runtime.js";
import { VERSION } from "../../version.js";
import { listWebSearchProviders, runWebSearch } from "../../web-search/runtime.js";
import { loadSiblingRuntimeModuleSync } from "./local-runtime-module.js";
import { createRuntimeAgent } from "./runtime-agent.js";
import { defineCachedValue } from "./runtime-cache.js";
import { createRuntimeChannel } from "./runtime-channel.js";
@@ -53,6 +50,27 @@ function createRuntimeMediaUnderstandingFacade(): PluginRuntime["mediaUnderstand
};
}
type RuntimeImageGenerationModule = typeof import("./runtime-image-generation.runtime.js");
let cachedRuntimeImageGenerationModule: RuntimeImageGenerationModule | null = null;
function loadRuntimeImageGenerationModule(): RuntimeImageGenerationModule {
cachedRuntimeImageGenerationModule ??= loadSiblingRuntimeModuleSync<RuntimeImageGenerationModule>(
{
moduleUrl: import.meta.url,
relativeBase: "./runtime-image-generation.runtime",
},
);
return cachedRuntimeImageGenerationModule;
}
function createRuntimeImageGeneration(): PluginRuntime["imageGeneration"] {
return {
generate: (params) => loadRuntimeImageGenerationModule().generateImage(params),
listProviders: (params) =>
loadRuntimeImageGenerationModule().listRuntimeImageGenerationProviders(params),
};
}
function createRuntimeModelAuth(): PluginRuntime["modelAuth"] {
const getApiKeyForModel = createLazyRuntimeMethod(
loadModelAuthRuntime,
@@ -175,10 +193,6 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}):
),
system: createRuntimeSystem(),
media: createRuntimeMedia(),
imageGeneration: {
generate: generateImage,
listProviders: listRuntimeImageGenerationProviders,
},
webSearch: {
listProviders: listWebSearchProviders,
search: runWebSearch,
@@ -187,8 +201,13 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}):
events: createRuntimeEvents(),
logging: createRuntimeLogging(),
state: { resolveStateDir },
} satisfies Omit<PluginRuntime, "tts" | "mediaUnderstanding" | "stt" | "modelAuth"> &
Partial<Pick<PluginRuntime, "tts" | "mediaUnderstanding" | "stt" | "modelAuth">>;
} satisfies Omit<
PluginRuntime,
"tts" | "mediaUnderstanding" | "stt" | "modelAuth" | "imageGeneration"
> &
Partial<
Pick<PluginRuntime, "tts" | "mediaUnderstanding" | "stt" | "modelAuth" | "imageGeneration">
>;
defineCachedValue(runtime, "tts", createRuntimeTts);
defineCachedValue(runtime, "mediaUnderstanding", () => mediaUnderstanding);
@@ -196,6 +215,7 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}):
transcribeAudioFile: mediaUnderstanding.transcribeAudioFile,
}));
defineCachedValue(runtime, "modelAuth", createRuntimeModelAuth);
defineCachedValue(runtime, "imageGeneration", createRuntimeImageGeneration);
return runtime as PluginRuntime;
}

View File

@@ -0,0 +1,51 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
shouldPreferNativeJiti,
} from "../sdk-alias.js";
const RUNTIME_MODULE_EXTENSIONS = [".js", ".ts", ".mjs", ".mts", ".cjs", ".cts"] as const;
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
function resolveSiblingRuntimeModulePath(moduleUrl: string, relativeBase: string): string {
const baseDir = path.dirname(fileURLToPath(moduleUrl));
for (const ext of RUNTIME_MODULE_EXTENSIONS) {
const candidate = path.resolve(baseDir, `${relativeBase}${ext}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
throw new Error(`Unable to resolve runtime module ${relativeBase} from ${moduleUrl}`);
}
function getJiti(modulePath: string, moduleUrl: string) {
const tryNative = shouldPreferNativeJiti(modulePath);
const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], moduleUrl);
const cacheKey = JSON.stringify({
tryNative,
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
moduleUrl,
});
const cached = jitiLoaders.get(cacheKey);
if (cached) {
return cached;
}
const loader = createJiti(moduleUrl, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative,
});
jitiLoaders.set(cacheKey, loader);
return loader;
}
export function loadSiblingRuntimeModuleSync<T>(params: {
moduleUrl: string;
relativeBase: string;
}): T {
const modulePath = resolveSiblingRuntimeModulePath(params.moduleUrl, params.relativeBase);
return getJiti(modulePath, params.moduleUrl)(modulePath) as T;
}

View File

@@ -56,29 +56,11 @@ import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import {
buildTemplateMessageFromPayload,
createQuickReplyItems,
monitorLineProvider,
probeLineBot,
pushFlexMessage,
pushLocationMessage,
pushMessageLine,
pushMessagesLine,
pushTemplateMessage,
pushTextMessageWithQuickReplies,
sendMessageLine,
} from "../../plugin-sdk/line-runtime.js";
import {
listLineAccountIds,
normalizeAccountId as normalizeLineAccountId,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "../../plugin-sdk/line.js";
import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js";
import { defineCachedValue } from "./runtime-cache.js";
import { createRuntimeDiscord } from "./runtime-discord.js";
import { createRuntimeIMessage } from "./runtime-imessage.js";
import { createRuntimeLine } from "./runtime-line.js";
import { createRuntimeMatrix } from "./runtime-matrix.js";
import { createRuntimeSignal } from "./runtime-signal.js";
import { createRuntimeSlack } from "./runtime-slack.js";
@@ -169,31 +151,14 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
shouldComputeCommandAuthorized,
shouldHandleTextCommands,
},
line: {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
normalizeAccountId: normalizeLineAccountId,
probeLineBot,
sendMessageLine,
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
monitorLineProvider,
},
} satisfies Omit<
PluginRuntime["channel"],
"discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp"
"discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp" | "line"
> &
Partial<
Pick<
PluginRuntime["channel"],
"discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp"
"discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp" | "line"
>
>;
@@ -204,6 +169,7 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
defineCachedValue(channelRuntime, "signal", createRuntimeSignal);
defineCachedValue(channelRuntime, "imessage", createRuntimeIMessage);
defineCachedValue(channelRuntime, "whatsapp", createRuntimeWhatsApp);
defineCachedValue(channelRuntime, "line", createRuntimeLine);
return channelRuntime as PluginRuntime["channel"];
}

View File

@@ -0,0 +1,4 @@
export {
generateImage,
listRuntimeImageGenerationProviders,
} from "../../plugin-sdk/image-generation-runtime.js";

View File

@@ -0,0 +1,38 @@
import {
buildTemplateMessageFromPayload,
createQuickReplyItems,
monitorLineProvider,
probeLineBot,
pushFlexMessage,
pushLocationMessage,
pushMessageLine,
pushMessagesLine,
pushTemplateMessage,
pushTextMessageWithQuickReplies,
sendMessageLine,
} from "../../plugin-sdk/line-runtime.js";
import {
listLineAccountIds,
normalizeAccountId,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "../../plugin-sdk/line.js";
import type { PluginRuntimeChannel } from "./types-channel.js";
export const runtimeLine = {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
normalizeAccountId,
probeLineBot,
sendMessageLine,
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
monitorLineProvider,
} satisfies PluginRuntimeChannel["line"];

View File

@@ -0,0 +1,46 @@
import { loadSiblingRuntimeModuleSync } from "./local-runtime-module.js";
import type { PluginRuntimeChannel } from "./types-channel.js";
type RuntimeLineModule = {
runtimeLine: PluginRuntimeChannel["line"];
};
let cachedRuntimeLineModule: RuntimeLineModule | null = null;
function loadRuntimeLineModule(): RuntimeLineModule {
cachedRuntimeLineModule ??= loadSiblingRuntimeModuleSync<RuntimeLineModule>({
moduleUrl: import.meta.url,
relativeBase: "./runtime-line.contract",
});
return cachedRuntimeLineModule;
}
export function createRuntimeLine(): PluginRuntimeChannel["line"] {
return {
listLineAccountIds: (...args) =>
loadRuntimeLineModule().runtimeLine.listLineAccountIds(...args),
resolveDefaultLineAccountId: (...args) =>
loadRuntimeLineModule().runtimeLine.resolveDefaultLineAccountId(...args),
resolveLineAccount: (...args) =>
loadRuntimeLineModule().runtimeLine.resolveLineAccount(...args),
normalizeAccountId: (...args) =>
loadRuntimeLineModule().runtimeLine.normalizeAccountId(...args),
probeLineBot: (...args) => loadRuntimeLineModule().runtimeLine.probeLineBot(...args),
sendMessageLine: (...args) => loadRuntimeLineModule().runtimeLine.sendMessageLine(...args),
pushMessageLine: (...args) => loadRuntimeLineModule().runtimeLine.pushMessageLine(...args),
pushMessagesLine: (...args) => loadRuntimeLineModule().runtimeLine.pushMessagesLine(...args),
pushFlexMessage: (...args) => loadRuntimeLineModule().runtimeLine.pushFlexMessage(...args),
pushTemplateMessage: (...args) =>
loadRuntimeLineModule().runtimeLine.pushTemplateMessage(...args),
pushLocationMessage: (...args) =>
loadRuntimeLineModule().runtimeLine.pushLocationMessage(...args),
pushTextMessageWithQuickReplies: (...args) =>
loadRuntimeLineModule().runtimeLine.pushTextMessageWithQuickReplies(...args),
createQuickReplyItems: (...args) =>
loadRuntimeLineModule().runtimeLine.createQuickReplyItems(...args),
buildTemplateMessageFromPayload: (...args) =>
loadRuntimeLineModule().runtimeLine.buildTemplateMessageFromPayload(...args),
monitorLineProvider: (...args) =>
loadRuntimeLineModule().runtimeLine.monitorLineProvider(...args),
};
}

View File

@@ -1,5 +1,5 @@
// Narrow plugin-sdk surface for the bundled matrix plugin.
// Keep this list additive and scoped to symbols used under extensions/matrix.
// Narrow plugin-sdk surface for the bundled Matrix plugin.
// Keep this list additive and scoped to the runtime contract only.
import { createOptionalChannelSetupSurface } from "../../plugin-sdk/channel-setup.js";

View File

@@ -2,6 +2,11 @@ import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, describe, expect, it, vi } from "vitest";
import {
bundledDistPluginFile,
bundledPluginFile,
bundledPluginRoot,
} from "../../test/helpers/bundled-plugin-paths.js";
import { withEnv } from "../test-utils/env.js";
import {
buildPluginLoaderAliasMap,
@@ -530,7 +535,10 @@ describe("plugin sdk alias helpers", () => {
it("builds plugin-sdk aliases from the module being loaded, not the loader location", () => {
const { fixture, sourceRootAlias, distRootAlias } = createPluginSdkAliasTargetFixture();
const sourcePluginEntry = writePluginEntry(fixture.root, "extensions/demo/src/index.ts");
const sourcePluginEntry = writePluginEntry(
fixture.root,
bundledPluginFile("demo", "src/index.ts"),
);
const sourceAliases = withEnv({ NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(sourcePluginEntry),
@@ -540,7 +548,10 @@ describe("plugin sdk alias helpers", () => {
channelRuntimePath: path.join(fixture.root, "src", "plugin-sdk", "channel-runtime.ts"),
});
const distPluginEntry = writePluginEntry(fixture.root, "dist/extensions/demo/index.js");
const distPluginEntry = writePluginEntry(
fixture.root,
bundledDistPluginFile("demo", "index.js"),
);
const distAliases = withEnv({ NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(distPluginEntry),
@@ -553,7 +564,10 @@ describe("plugin sdk alias helpers", () => {
it("applies explicit dist resolution to plugin-sdk subpath aliases too", () => {
const { fixture, distRootAlias } = createPluginSdkAliasTargetFixture();
const sourcePluginEntry = writePluginEntry(fixture.root, "extensions/demo/src/index.ts");
const sourcePluginEntry = writePluginEntry(
fixture.root,
bundledPluginFile("demo", "src/index.ts"),
);
const distAliases = withEnv({ NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(sourcePluginEntry, undefined, undefined, "dist"),
@@ -663,7 +677,9 @@ describe("plugin sdk alias helpers", () => {
it("uses transpiled Jiti loads for source TypeScript plugin entries", () => {
expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(shouldPreferNativeJiti("/repo/extensions/discord/src/channel.runtime.ts")).toBe(false);
expect(
shouldPreferNativeJiti(`/repo/${bundledPluginFile("discord", "src/channel.runtime.ts")}`),
).toBe(false);
});
it("disables native Jiti loads under Bun even for built JavaScript entries", () => {
@@ -678,7 +694,9 @@ describe("plugin sdk alias helpers", () => {
try {
expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(false);
expect(shouldPreferNativeJiti("/repo/dist/extensions/browser/index.js")).toBe(false);
expect(shouldPreferNativeJiti(`/repo/${bundledDistPluginFile("browser", "index.js")}`)).toBe(
false,
);
} finally {
Object.defineProperty(process, "versions", {
configurable: true,
@@ -688,7 +706,7 @@ describe("plugin sdk alias helpers", () => {
});
it("loads source runtime shims through the non-native Jiti boundary", async () => {
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "discord");
const copiedExtensionRoot = path.join(makeTempDir(), bundledPluginRoot("discord"));
const copiedSourceDir = path.join(copiedExtensionRoot, "src");
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
mkdirSafeDir(copiedSourceDir);

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs";
import { bundledDistPluginFile } from "../../test/helpers/bundled-plugin-paths.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
@@ -33,6 +34,10 @@ function setupRepoFiles(repoRoot: string, files: Readonly<Record<string, string>
}
}
function distRuntimeImportPath(pluginId: string, relativePath = "index.js"): string {
return `../../../${bundledDistPluginFile(pluginId, relativePath)}`;
}
function expectRuntimePluginWrapperContains(params: {
repoRoot: string;
pluginId: string;
@@ -83,8 +88,9 @@ describe("stageBundledPluginRuntime", () => {
recursive: true,
});
setupRepoFiles(repoRoot, {
"dist/extensions/diffs/index.js": "export default {}\n",
"dist/extensions/diffs/node_modules/@pierre/diffs/index.js": "export default {}\n",
[bundledDistPluginFile("diffs", "index.js")]: "export default {}\n",
[bundledDistPluginFile("diffs", "node_modules/@pierre/diffs/index.js")]:
"export default {}\n",
});
stageBundledPluginRuntime({ repoRoot });
@@ -93,7 +99,7 @@ describe("stageBundledPluginRuntime", () => {
expectRuntimePluginWrapperContains({
repoRoot,
pluginId: "diffs",
expectedImport: "../../../dist/extensions/diffs/index.js",
expectedImport: distRuntimeImportPath("diffs"),
});
expect(fs.lstatSync(path.join(runtimePluginDir, "node_modules")).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(path.join(runtimePluginDir, "node_modules"))).toBe(
@@ -107,7 +113,7 @@ describe("stageBundledPluginRuntime", () => {
createDistPluginDir(repoRoot, "diffs");
setupRepoFiles(repoRoot, {
"dist/chunk-abc.js": "export const value = 1;\n",
"dist/extensions/diffs/index.js": "export { value } from '../../chunk-abc.js';\n",
[bundledDistPluginFile("diffs", "index.js")]: "export { value } from '../../chunk-abc.js';\n",
});
stageBundledPluginRuntime({ repoRoot });
@@ -116,7 +122,7 @@ describe("stageBundledPluginRuntime", () => {
expectRuntimePluginWrapperContains({
repoRoot,
pluginId: "diffs",
expectedImport: "../../../dist/extensions/diffs/index.js",
expectedImport: distRuntimeImportPath("diffs"),
});
expect(fs.existsSync(path.join(repoRoot, "dist-runtime", "chunk-abc.js"))).toBe(false);
@@ -128,9 +134,9 @@ describe("stageBundledPluginRuntime", () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-sidecars-");
createDistPluginDir(repoRoot, "whatsapp");
setupRepoFiles(repoRoot, {
"dist/extensions/whatsapp/index.js": "export default {};\n",
"dist/extensions/whatsapp/light-runtime-api.js": "export const light = true;\n",
"dist/extensions/whatsapp/runtime-api.js": "export const heavy = true;\n",
[bundledDistPluginFile("whatsapp", "index.js")]: "export default {};\n",
[bundledDistPluginFile("whatsapp", "light-runtime-api.js")]: "export const light = true;\n",
[bundledDistPluginFile("whatsapp", "runtime-api.js")]: "export const heavy = true;\n",
});
stageBundledPluginRuntime({ repoRoot });
@@ -139,13 +145,13 @@ describe("stageBundledPluginRuntime", () => {
repoRoot,
pluginId: "whatsapp",
relativePath: "light-runtime-api.js",
expectedImport: "../../../dist/extensions/whatsapp/light-runtime-api.js",
expectedImport: distRuntimeImportPath("whatsapp", "light-runtime-api.js"),
});
expectRuntimePluginWrapperContains({
repoRoot,
pluginId: "whatsapp",
relativePath: "runtime-api.js",
expectedImport: "../../../dist/extensions/whatsapp/runtime-api.js",
expectedImport: distRuntimeImportPath("whatsapp", "runtime-api.js"),
});
});
@@ -260,13 +266,13 @@ describe("stageBundledPluginRuntime", () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-assets-");
createDistPluginDir(repoRoot, "diffs");
setupRepoFiles(repoRoot, {
"dist/extensions/diffs/package.json": JSON.stringify(
[bundledDistPluginFile("diffs", "package.json")]: JSON.stringify(
{ name: "@openclaw/diffs", openclaw: { extensions: ["./index.js"] } },
null,
2,
),
"dist/extensions/diffs/openclaw.plugin.json": "{}\n",
"dist/extensions/diffs/assets/info.txt": "ok\n",
[bundledDistPluginFile("diffs", "openclaw.plugin.json")]: "{}\n",
[bundledDistPluginFile("diffs", "assets/info.txt")]: "ok\n",
});
stageBundledPluginRuntime({ repoRoot });
@@ -301,7 +307,7 @@ describe("stageBundledPluginRuntime", () => {
const runtimeExtensionsDir = path.join(repoRoot, "dist-runtime", "extensions");
createDistPluginDir(repoRoot, "demo");
setupRepoFiles(repoRoot, {
"dist/extensions/demo/package.json": JSON.stringify(
[bundledDistPluginFile("demo", "package.json")]: JSON.stringify(
{
name: "@openclaw/demo",
openclaw: {
@@ -315,7 +321,7 @@ describe("stageBundledPluginRuntime", () => {
null,
2,
),
"dist/extensions/demo/openclaw.plugin.json": JSON.stringify(
[bundledDistPluginFile("demo", "openclaw.plugin.json")]: JSON.stringify(
{
id: "demo",
channels: ["demo"],
@@ -324,8 +330,8 @@ describe("stageBundledPluginRuntime", () => {
null,
2,
),
"dist/extensions/demo/main.js": "export default {};\n",
"dist/extensions/demo/setup.js": "export default {};\n",
[bundledDistPluginFile("demo", "main.js")]: "export default {};\n",
[bundledDistPluginFile("demo", "setup.js")]: "export default {};\n",
});
stageBundledPluginRuntime({ repoRoot });
@@ -390,8 +396,8 @@ describe("stageBundledPluginRuntime", () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-eexist-");
createDistPluginDir(repoRoot, "feishu");
setupRepoFiles(repoRoot, {
"dist/extensions/feishu/index.js": "export default {}\n",
"dist/extensions/feishu/skills/feishu-doc/SKILL.md": "# Feishu Doc\n",
[bundledDistPluginFile("feishu", "index.js")]: "export default {}\n",
[bundledDistPluginFile("feishu", "skills/feishu-doc/SKILL.md")]: "# Feishu Doc\n",
});
const realSymlinkSync = fs.symlinkSync.bind(fs);

View File

@@ -777,7 +777,7 @@ describe("resolveUninstallDirectoryTarget", () => {
installRecord: {
source: "npm",
spec: "my-plugin@1.0.0",
installPath: "/tmp/not-openclaw-extensions/my-plugin",
installPath: "/tmp/not-openclaw-plugin-install/my-plugin",
},
extensionsDir,
});

View File

@@ -1,6 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { bundledPluginRootAt } from "../../test/helpers/bundled-plugin-paths.js";
import type { OpenClawConfig } from "../config/config.js";
const APP_ROOT = "/app";
function appBundledPluginRoot(pluginId: string): string {
return bundledPluginRootAt(APP_ROOT, pluginId);
}
const installPluginFromNpmSpecMock = vi.fn();
const installPluginFromMarketplaceMock = vi.fn();
const installPluginFromClawHubMock = vi.fn();
@@ -131,7 +138,7 @@ function createBundledPathInstallConfig(params: {
installs: {
feishu: {
source: "path",
sourcePath: params.sourcePath ?? "/app/extensions/feishu",
sourcePath: params.sourcePath ?? appBundledPluginRoot("feishu"),
installPath: params.installPath,
...(params.spec ? { spec: params.spec } : {}),
},
@@ -178,7 +185,7 @@ function createBundledSource(params?: { pluginId?: string; localPath?: string; n
const pluginId = params?.pluginId ?? "feishu";
return {
pluginId,
localPath: params?.localPath ?? `/app/extensions/${pluginId}`,
localPath: params?.localPath ?? appBundledPluginRoot(pluginId),
npmSpec: params?.npmSpec ?? `@openclaw/${pluginId}`,
};
}
@@ -610,13 +617,13 @@ describe("syncPluginsForUpdateChannel", () => {
{
name: "keeps bundled path installs on beta without reinstalling from npm",
config: createBundledPathInstallConfig({
loadPaths: ["/app/extensions/feishu"],
installPath: "/app/extensions/feishu",
loadPaths: [appBundledPluginRoot("feishu")],
installPath: appBundledPluginRoot("feishu"),
spec: "@openclaw/feishu",
}),
expectedChanged: false,
expectedLoadPaths: ["/app/extensions/feishu"],
expectedInstallPath: "/app/extensions/feishu",
expectedLoadPaths: [appBundledPluginRoot("feishu")],
expectedInstallPath: appBundledPluginRoot("feishu"),
},
{
name: "repairs bundled install metadata when the load path is re-added",
@@ -626,8 +633,8 @@ describe("syncPluginsForUpdateChannel", () => {
spec: "@openclaw/feishu",
}),
expectedChanged: true,
expectedLoadPaths: ["/app/extensions/feishu"],
expectedInstallPath: "/app/extensions/feishu",
expectedLoadPaths: [appBundledPluginRoot("feishu")],
expectedInstallPath: appBundledPluginRoot("feishu"),
},
] as const)(
"$name",
@@ -645,7 +652,7 @@ describe("syncPluginsForUpdateChannel", () => {
expect(result.config.plugins?.load?.paths).toEqual(expectedLoadPaths);
expectBundledPathInstall({
install: result.config.plugins?.installs?.feishu,
sourcePath: "/app/extensions/feishu",
sourcePath: appBundledPluginRoot("feishu"),
installPath: expectedInstallPath,
spec: "@openclaw/feishu",
});