refactor: route bundled catalogs through plugin registry

This commit is contained in:
Peter Steinberger
2026-05-02 01:55:09 +01:00
parent ef3ce37cd3
commit eef8dab4e9
10 changed files with 213 additions and 196 deletions

View File

@@ -27,13 +27,35 @@ import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
import { listBundledChannelCatalogEntries } from "./bundled-channel-catalog-read.js";
const tempDirs: string[] = [];
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const originalTrustBundledPluginsDir = process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR;
afterEach(() => {
if (originalBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
}
if (originalTrustBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = originalTrustBundledPluginsDir;
}
cleanupTempDirs(tempDirs);
vi.restoreAllMocks();
vi.mocked(resolveBundledPluginsDir).mockReset();
});
function useBundledPluginsDir(extensionsRoot: string | undefined): void {
if (extensionsRoot) {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = extensionsRoot;
process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = "1";
} else {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
}
vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot);
}
function seedRoot(prefix: string): string {
const root = makeTempRepoRoot(tempDirs, prefix);
writeJsonFile(path.join(root, "package.json"), { name: "openclaw" });
@@ -45,6 +67,7 @@ function seedChannelPkg(
pkgJsonPath: string,
opts: { id: string; docsPath: string; label?: string; blurb?: string },
): void {
const pluginDir = path.dirname(pkgJsonPath);
writeJsonFile(pkgJsonPath, {
name: `@openclaw/${opts.id}`,
openclaw: {
@@ -56,6 +79,12 @@ function seedChannelPkg(
},
},
});
writeJsonFile(path.join(pluginDir, "openclaw.plugin.json"), {
id: opts.id,
configSchema: { type: "object" },
channels: [opts.id],
});
fs.writeFileSync(path.join(pluginDir, "index.js"), "export default { register() {} };\n", "utf8");
}
describe("listBundledChannelCatalogEntries", () => {
@@ -75,7 +104,7 @@ describe("listBundledChannelCatalogEntries", () => {
id: "imessage",
docsPath: "/channels/imessage",
});
vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot);
useBundledPluginsDir(extensionsRoot);
const entries = listBundledChannelCatalogEntries();
@@ -109,7 +138,7 @@ describe("listBundledChannelCatalogEntries", () => {
},
],
});
vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot);
useBundledPluginsDir(extensionsRoot);
const entries = listBundledChannelCatalogEntries();
expect(entries.map((entry) => entry.id)).toEqual(expect.arrayContaining(["qqbot", "telegram"]));
@@ -136,7 +165,7 @@ describe("listBundledChannelCatalogEntries", () => {
},
],
});
vi.mocked(resolveBundledPluginsDir).mockReturnValue(undefined);
useBundledPluginsDir(undefined);
const entries = listBundledChannelCatalogEntries();
expect(entries.map((entry) => entry.id)).toContain("fallback-channel");
@@ -165,7 +194,7 @@ describe("listBundledChannelCatalogEntries", () => {
},
],
});
vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot);
useBundledPluginsDir(extensionsRoot);
const entries = listBundledChannelCatalogEntries();
expect(entries.map((entry) => entry.id)).toContain("fallback-channel");

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
import { listChannelCatalogEntries } from "../plugins/channel-catalog-registry.js";
import type { PluginPackageChannel } from "../plugins/manifest.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
@@ -27,43 +27,8 @@ function listPackageRoots(): string[] {
].filter((entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index);
}
function listBundledExtensionPackageJsonPaths(env: NodeJS.ProcessEnv = process.env): string[] {
// Delegate to the plugin loader's resolver so channel metadata stays in lock
// step with whichever bundled plugin tree is actually loaded at runtime
// (source extensions/ in dev/test, dist/extensions in published installs,
// dist-runtime/extensions when paired with dist, etc.). See
// src/plugins/bundled-dir.ts for the full candidate-order policy and
// src/plugins/bundled-dir.test.ts for the precedence coverage. Reusing the
// resolver also picks up OPENCLAW_BUNDLED_PLUGINS_DIR overrides and the
// bun --compile sibling layout for free.
const extensionsRoot = resolveBundledPluginsDir(env);
if (!extensionsRoot) {
return [];
}
try {
return fs
.readdirSync(extensionsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(extensionsRoot, entry.name, "package.json"))
.filter((entry) => fs.existsSync(entry));
} catch {
return [];
}
}
function readBundledExtensionCatalogEntriesSync(): ChannelCatalogEntryLike[] {
const entries: ChannelCatalogEntryLike[] = [];
for (const packageJsonPath of listBundledExtensionPackageJsonPaths()) {
try {
const payload = JSON.parse(
fs.readFileSync(packageJsonPath, "utf8"),
) as ChannelCatalogEntryLike;
entries.push(payload);
} catch {
continue;
}
}
return entries;
function readBundledExtensionCatalogEntriesSync(): PluginPackageChannel[] {
return listChannelCatalogEntries({ origin: "bundled" }).map((entry) => entry.channel);
}
function readOfficialCatalogFileSync(): ChannelCatalogEntryLike[] {
@@ -84,8 +49,18 @@ function readOfficialCatalogFileSync(): ChannelCatalogEntryLike[] {
return [];
}
function toBundledChannelEntry(entry: ChannelCatalogEntryLike): BundledChannelCatalogEntry | null {
const channel = entry.openclaw?.channel;
function isChannelCatalogEntryLike(
entry: ChannelCatalogEntryLike | PluginPackageChannel,
): entry is ChannelCatalogEntryLike {
return "openclaw" in entry;
}
function toBundledChannelEntry(
entry: ChannelCatalogEntryLike | PluginPackageChannel,
): BundledChannelCatalogEntry | null {
const channel: PluginPackageChannel | undefined = isChannelCatalogEntryLike(entry)
? entry.openclaw?.channel
: entry;
const id = normalizeOptionalLowercaseString(channel?.id);
if (!id || !channel) {
return null;

View File

@@ -1,10 +1,13 @@
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { BundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { PluginManifestChannelConfig } from "../plugins/manifest.js";
const listBundledPluginMetadataMock = vi.hoisted(() =>
vi.fn<(options?: unknown) => readonly BundledPluginMetadata[]>(() => []),
const loadPluginManifestRegistryMock = vi.hoisted(() =>
vi.fn<(options?: Record<string, unknown>) => PluginManifestRegistry>(() => ({
plugins: [],
diagnostics: [],
})),
);
const collectBundledChannelConfigsMock = vi.hoisted(() =>
vi.fn<(params: unknown) => Record<string, PluginManifestChannelConfig> | undefined>(
@@ -14,10 +17,15 @@ const collectBundledChannelConfigsMock = vi.hoisted(() =>
describe("ChannelsSchema bundled runtime loading", () => {
beforeEach(() => {
listBundledPluginMetadataMock.mockClear();
loadPluginManifestRegistryMock.mockClear();
loadPluginManifestRegistryMock.mockReturnValue({
plugins: [],
diagnostics: [],
});
collectBundledChannelConfigsMock.mockClear();
vi.doMock("../plugins/bundled-plugin-metadata.js", () => ({
listBundledPluginMetadata: (options?: unknown) => listBundledPluginMetadataMock(options),
vi.doMock("../plugins/plugin-registry.js", () => ({
loadPluginManifestRegistryForPluginRegistry: (options?: Record<string, unknown>) =>
loadPluginManifestRegistryMock(options),
}));
vi.doMock("../plugins/bundled-channel-config-metadata.js", () => ({
collectBundledChannelConfigs: (params: unknown) => collectBundledChannelConfigsMock(params),
@@ -42,18 +50,20 @@ describe("ChannelsSchema bundled runtime loading", () => {
});
expect(parsed?.defaults?.groupPolicy).toBe("open");
expect(listBundledPluginMetadataMock).not.toHaveBeenCalledWith(
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalledWith(
expect.objectContaining({
includeChannelConfigs: true,
bundledChannelConfigCollector: expect.any(Function),
}),
);
});
it("loads bundled channel runtime discovery only when plugin-owned channel config is present", async () => {
listBundledPluginMetadataMock.mockReturnValueOnce([
{
dirName: "discord",
manifest: {
loadPluginManifestRegistryMock.mockReturnValueOnce({
diagnostics: [],
plugins: [
{
id: "discord",
origin: "bundled",
channels: ["discord"],
channelConfigs: {
discord: {
@@ -62,9 +72,9 @@ describe("ChannelsSchema bundled runtime loading", () => {
},
},
},
},
} as unknown as BundledPluginMetadata,
]);
} as unknown as PluginManifestRegistry["plugins"][number],
],
});
const runtime = await importFreshModule<typeof import("./zod-schema.providers.js")>(
import.meta.url,
@@ -75,24 +85,16 @@ describe("ChannelsSchema bundled runtime loading", () => {
discord: {},
});
expect(listBundledPluginMetadataMock.mock.calls).toContainEqual([
expect(loadPluginManifestRegistryMock.mock.calls).toContainEqual([
expect.objectContaining({
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
includeDisabled: true,
bundledChannelConfigCollector: expect.any(Function),
}),
]);
expect(collectBundledChannelConfigsMock).not.toHaveBeenCalled();
});
it("loads a single plugin-owned runtime surface when the manifest omits runtime metadata", async () => {
listBundledPluginMetadataMock.mockReturnValueOnce([
{
dirName: "discord",
manifest: {
channels: ["discord"],
},
} as unknown as BundledPluginMetadata,
]);
collectBundledChannelConfigsMock.mockReturnValueOnce({
discord: {
schema: {},
@@ -101,6 +103,24 @@ describe("ChannelsSchema bundled runtime loading", () => {
},
},
});
loadPluginManifestRegistryMock.mockImplementationOnce((options) => ({
diagnostics: [],
plugins: [
{
id: "discord",
origin: "bundled",
channels: ["discord"],
channelConfigs: (
options?.bundledChannelConfigCollector as
| ((params: unknown) => Record<string, PluginManifestChannelConfig> | undefined)
| undefined
)?.({
pluginDir: "/repo/extensions/discord",
manifest: { id: "discord", channels: ["discord"] },
}),
} as unknown as PluginManifestRegistry["plugins"][number],
],
}));
const runtime = await importFreshModule<typeof import("./zod-schema.providers.js")>(
import.meta.url,
@@ -111,10 +131,10 @@ describe("ChannelsSchema bundled runtime loading", () => {
discord: {},
});
expect(listBundledPluginMetadataMock.mock.calls).toContainEqual([
expect(loadPluginManifestRegistryMock.mock.calls).toContainEqual([
expect.objectContaining({
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
includeDisabled: true,
bundledChannelConfigCollector: expect.any(Function),
}),
]);
expect(collectBundledChannelConfigsMock).toHaveBeenCalledTimes(1);

View File

@@ -1,10 +1,6 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.config.js";
import { collectBundledChannelConfigs } from "../plugins/bundled-channel-config-metadata.js";
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js";
import type { ChannelsConfig } from "./types.channels.js";
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
import { ContextVisibilityModeSchema, GroupPolicySchema } from "./zod-schema.core.js";
@@ -17,36 +13,12 @@ const ChannelModelByChannelSchema = z
.record(z.string(), z.record(z.string(), z.string()))
.optional();
const OPENCLAW_PACKAGE_ROOT =
resolveLoaderPackageRoot({
modulePath: fileURLToPath(import.meta.url),
moduleUrl: import.meta.url,
}) ?? fileURLToPath(new URL("../..", import.meta.url));
function getDirectChannelRuntimeSchema(channelId: string): ChannelConfigRuntimeSchema | undefined {
for (const entry of listBundledPluginMetadata({
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
})) {
const manifestRuntime = entry.manifest.channelConfigs?.[channelId]?.runtime;
if (manifestRuntime) {
return manifestRuntime;
}
if (!entry.manifest.channels?.includes(channelId)) {
continue;
}
const collectedChannelConfigs = collectBundledChannelConfigs({
pluginDir: path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions", entry.dirName),
manifest: entry.manifest,
...(entry.packageManifest ? { packageManifest: entry.packageManifest } : {}),
});
const collectedRuntime = collectedChannelConfigs?.[channelId]?.runtime;
if (collectedRuntime) {
return collectedRuntime;
}
}
return undefined;
function getDirectChannelRuntimeSchema(channelId: string) {
return loadPluginManifestRegistryForPluginRegistry({
includeDisabled: true,
bundledChannelConfigCollector: collectBundledChannelConfigs,
}).plugins.find((plugin) => plugin.origin === "bundled" && plugin.channelConfigs?.[channelId])
?.channelConfigs?.[channelId]?.runtime;
}
function hasPluginOwnedChannelConfig(

View File

@@ -1,3 +1,4 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "../../test/helpers/temp-repo.js";
@@ -10,13 +11,31 @@ import { resolveBundledPluginsDir } from "./bundled-dir.js";
import { findBundledPackageChannelMetadata } from "./bundled-package-channel-metadata.js";
const tempDirs: string[] = [];
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const originalTrustBundledPluginsDir = process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR;
afterEach(() => {
if (originalBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
}
if (originalTrustBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = originalTrustBundledPluginsDir;
}
cleanupTempDirs(tempDirs);
vi.restoreAllMocks();
vi.mocked(resolveBundledPluginsDir).mockReset();
});
function useBundledPluginsDir(extensionsRoot: string): void {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = extensionsRoot;
process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = "1";
vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot);
}
describe("bundled package channel metadata", () => {
it("reads doctor capabilities from the resolved bundled plugin dir", () => {
const root = makeTempRepoRoot(tempDirs, "bpcm-");
@@ -37,7 +56,17 @@ describe("bundled package channel metadata", () => {
},
},
});
vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot);
writeJsonFile(path.join(extensionsRoot, "matrix", "openclaw.plugin.json"), {
id: "matrix",
configSchema: { type: "object" },
channels: ["matrix"],
});
fs.writeFileSync(
path.join(extensionsRoot, "matrix", "index.js"),
"export default {};\n",
"utf8",
);
useBundledPluginsDir(extensionsRoot);
const matrix = findBundledPackageChannelMetadata("matrix");
@@ -53,7 +82,7 @@ describe("bundled package channel metadata", () => {
const root = makeTempRepoRoot(tempDirs, "bpcm-fresh-");
const extensionsRoot = path.join(root, "dist", "extensions");
const packagePath = path.join(extensionsRoot, "matrix", "package.json");
vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot);
useBundledPluginsDir(extensionsRoot);
writeJsonFile(packagePath, {
name: "@openclaw/matrix",
@@ -64,6 +93,16 @@ describe("bundled package channel metadata", () => {
},
},
});
writeJsonFile(path.join(extensionsRoot, "matrix", "openclaw.plugin.json"), {
id: "matrix",
configSchema: { type: "object" },
channels: ["matrix"],
});
fs.writeFileSync(
path.join(extensionsRoot, "matrix", "index.js"),
"export default {};\n",
"utf8",
);
expect(findBundledPackageChannelMetadata("matrix")?.label).toBe("Before");
writeJsonFile(packagePath, {

View File

@@ -1,35 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import {
getPackageManifestMetadata,
type PackageManifest,
type PluginPackageChannel,
} from "./manifest.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;
}
}
import { listChannelCatalogEntries } from "./channel-catalog-registry.js";
import type { PluginPackageChannel } from "./manifest.js";
export function listBundledPackageChannelMetadata(): readonly PluginPackageChannel[] {
const scanDir = resolveBundledPluginsDir();
if (!scanDir || !fs.existsSync(scanDir)) {
return [];
}
return fs
.readdirSync(scanDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => readPackageManifest(path.join(scanDir, entry.name)))
.map((manifest) => getPackageManifestMetadata(manifest)?.channel)
.filter((channel): channel is PluginPackageChannel => Boolean(channel?.id));
return listChannelCatalogEntries({ origin: "bundled" }).map((entry) => entry.channel);
}
export function findBundledPackageChannelMetadata(

View File

@@ -1,5 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import {
listExplicitlyDisabledChannelIdsForConfig,
listPotentialConfiguredChannelIds,
@@ -7,7 +5,6 @@ import {
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import {
listExplicitConfiguredChannelIdsForConfig,
resolveConfiguredChannelPluginIds,
@@ -15,7 +12,7 @@ import {
} from "./channel-plugin-ids.js";
import { normalizePluginsConfig } from "./config-state.js";
import { passesManifestOwnerBasePolicy } from "./manifest-owner-policy.js";
import { loadPluginManifest } from "./manifest.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
function collectConfiguredChannelIds(
config: OpenClawConfig,
@@ -45,6 +42,7 @@ function collectBundledChannelOwnerPluginIds(params: {
config: OpenClawConfig;
channelIds: readonly string[];
env: NodeJS.ProcessEnv;
workspaceDir?: string;
bundledPluginsDir?: string;
}): string[] {
const plugins = normalizePluginsConfig(params.config.plugins);
@@ -56,32 +54,32 @@ function collectBundledChannelOwnerPluginIds(params: {
if (channelIds.size === 0) {
return [];
}
const bundledDir = params.bundledPluginsDir ?? resolveBundledPluginsDir(params.env);
if (!bundledDir) {
return [];
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(bundledDir, { withFileTypes: true });
} catch {
return [];
}
const env = params.bundledPluginsDir
? {
...params.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: params.bundledPluginsDir,
...(params.env.VITEST || process.env.VITEST
? { OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1" }
: {}),
}
: params.env;
const registry = loadPluginManifestRegistryForPluginRegistry({
config: params.config,
env,
workspaceDir: params.workspaceDir,
includeDisabled: true,
});
const pluginIds = new Set<string>();
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const pluginDir = path.join(bundledDir, entry.name);
const manifest = loadPluginManifest(pluginDir, false);
if (!manifest.ok) {
for (const plugin of registry.plugins) {
if (plugin.origin !== "bundled") {
continue;
}
if (
(manifest.manifest.channels ?? []).some((channelId) =>
plugin.channels.some((channelId) =>
channelIds.has(normalizeOptionalLowercaseString(channelId) ?? ""),
)
) {
const pluginId = normalizeOptionalLowercaseString(manifest.manifest.id);
const pluginId = normalizeOptionalLowercaseString(plugin.id);
if (
pluginId &&
passesManifestOwnerBasePolicy({
@@ -152,6 +150,7 @@ export function resolveEffectivePluginIds(params: {
config: effectiveConfig,
channelIds: configuredChannelIds,
env: params.env,
workspaceDir: params.workspaceDir,
...(params.bundledPluginsDir ? { bundledPluginsDir: params.bundledPluginsDir } : {}),
})) {
ids.add(pluginId);

View File

@@ -7,7 +7,20 @@ import type { ModelProviderConfig } from "../config/types.models.js";
import { resolveBundledProviderPolicySurface } from "./provider-public-artifacts.js";
describe("provider public artifacts", () => {
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const originalTrustBundledPluginsDir = process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR;
afterEach(() => {
if (originalBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
}
if (originalTrustBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = originalTrustBundledPluginsDir;
}
vi.doUnmock("./bundled-dir.js");
vi.doUnmock("./public-surface-loader.js");
vi.resetModules();
@@ -36,7 +49,16 @@ describe("provider public artifacts", () => {
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify({ providers: ["openai", "openai-codex"] }),
JSON.stringify({
id: "openai",
configSchema: { type: "object" },
providers: ["openai", "openai-codex"],
}),
);
fs.writeFileSync(
path.join(pluginDir, "index.js"),
"export default { register() {} };\n",
"utf8",
);
const resolveThinkingProfile = vi.fn(({ modelId }: { modelId: string }) => ({
@@ -52,6 +74,8 @@ describe("provider public artifacts", () => {
vi.doMock("./bundled-dir.js", () => ({
resolveBundledPluginsDir: () => bundledPluginsDir,
}));
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir;
process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = "1";
vi.doMock("./public-surface-loader.js", () => ({
loadBundledPluginPublicArtifactModuleSync,
}));

View File

@@ -1,9 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import { normalizeProviderId } from "../agents/provider-id.js";
import type { ModelProviderConfig } from "../config/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type {
ProviderApplyConfigDefaultsContext,
ProviderNormalizeConfigContext,
@@ -76,34 +75,24 @@ function resolveBundledProviderPolicyPluginId(providerId: string): string | null
return providerPolicyPluginIdsByProviderId.get(cacheKey) ?? null;
}
if (!bundledPluginsDir || !fs.existsSync(bundledPluginsDir)) {
if (!bundledPluginsDir) {
providerPolicyPluginIdsByProviderId.set(cacheKey, null);
return null;
}
for (const entry of fs
.readdirSync(bundledPluginsDir, { withFileTypes: true })
.filter((candidate) => candidate.isDirectory())
.map((candidate) => candidate.name)
.toSorted((left, right) => left.localeCompare(right))) {
const manifestPath = path.join(bundledPluginsDir, entry, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {
const registry = loadPluginManifestRegistry();
for (const plugin of registry.plugins.toSorted((left, right) =>
left.id.localeCompare(right.id),
)) {
if (plugin.origin !== "bundled") {
continue;
}
let manifest: { providers?: unknown };
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as { providers?: unknown };
} catch {
continue;
}
const providers = Array.isArray(manifest.providers) ? manifest.providers : [];
const ownsProvider = providers.some(
(candidate) =>
typeof candidate === "string" && normalizeProviderId(candidate) === normalizedProviderId,
const ownsProvider = plugin.providers.some(
(provider) => normalizeProviderId(provider) === normalizedProviderId,
);
if (ownsProvider) {
providerPolicyPluginIdsByProviderId.set(cacheKey, entry);
return entry;
providerPolicyPluginIdsByProviderId.set(cacheKey, plugin.id);
return plugin.id;
}
}

View File

@@ -1,4 +1,3 @@
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js";
import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js";
@@ -70,16 +69,14 @@ function listBundledWebProviderSecretTargetRegistryEntries(): SecretTargetRegist
function listBundledPluginConfigSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] {
const entries: SecretTargetRegistryEntry[] = [];
const seen = new Set<string>();
for (const record of listBundledPluginMetadata({
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
})) {
const secretInputs = record.manifest.configContracts?.secretInputs?.paths ?? [];
for (const record of loadPluginManifestRegistryForPluginRegistry({ includeDisabled: true })
.plugins) {
if (record.origin !== "bundled") {
continue;
}
const secretInputs = record.configContracts?.secretInputs?.paths ?? [];
for (const secretInput of secretInputs) {
const entry = createPluginOpenClawConfigSecretTargetEntry(
record.manifest.id,
secretInput.path,
);
const entry = createPluginOpenClawConfigSecretTargetEntry(record.id, secretInput.path);
const key = `${entry.configFile}:${entry.pathPattern}`;
if (seen.has(key)) {
continue;