fix(plugins): keep config schema on manifest metadata

This commit is contained in:
Peter Steinberger
2026-04-28 01:44:22 +01:00
parent 45a84b5f95
commit 13d3777cf3
5 changed files with 134 additions and 8 deletions

View File

@@ -191,6 +191,10 @@ describe("readBestEffortRuntimeConfigSchema", () => {
cache: true,
}),
);
expect(mockLoadPluginManifestRegistry.mock.calls[0]?.[0]).not.toHaveProperty("cache", false);
expect(mockLoadPluginManifestRegistry.mock.calls[0]?.[0]).not.toHaveProperty(
"bundledChannelConfigCollector",
);
expect(channelProps?.telegram).toBeTruthy();
expect(channelProps?.matrix).toBeTruthy();
expect(entryProps?.demo).toBeTruthy();
@@ -207,6 +211,10 @@ describe("readBestEffortRuntimeConfigSchema", () => {
cache: true,
}),
);
expect(mockLoadPluginManifestRegistry.mock.calls[0]?.[0]).not.toHaveProperty("cache", false);
expect(mockLoadPluginManifestRegistry.mock.calls[0]?.[0]).not.toHaveProperty(
"bundledChannelConfigCollector",
);
expect(channelProps?.telegram).toBeTruthy();
expect(channelProps?.slack).toBeTruthy();
expect(entryProps?.demo).toBeUndefined();

View File

@@ -24,7 +24,7 @@ const SOURCE_CONFIG_SCHEMA_CANDIDATES = [
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;
const PUBLIC_CONFIG_SURFACE_BASENAMES = ["channel-config-api"] as const;
type ChannelConfigSurface = {
schema: JsonSchemaObject;

View File

@@ -614,4 +614,82 @@ describe("bundled plugin metadata", () => {
},
});
});
it("does not probe broad runtime public surfaces for channel config metadata", () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-dist-config-runtime-");
const distRoot = path.join(tempRoot, "dist");
const markerPath = path.join(tempRoot, "runtime-api-loaded");
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",
configSchema: {
type: "object",
properties: {},
},
channels: ["alpha"],
channelConfigs: {
alpha: {
schema: { type: "object", properties: { manifest: { type: "boolean" } } },
},
},
});
fs.writeFileSync(
path.join(distRoot, "extensions", "alpha", "index.js"),
"export {};\n",
"utf8",
);
fs.writeFileSync(
path.join(distRoot, "extensions", "alpha", "runtime-api.js"),
[
"import fs from 'node:fs';",
`fs.writeFileSync(${JSON.stringify(markerPath)}, "loaded", "utf8");`,
"export const AlphaChannelConfigSchema = {",
" schema: { type: 'object', properties: { runtimeApi: { type: 'string' } } },",
"};",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(
path.join(distRoot, "extensions", "alpha", "api.js"),
[
"import fs from 'node:fs';",
`fs.writeFileSync(${JSON.stringify(markerPath)}, "loaded", "utf8");`,
"export const AlphaChannelConfigSchema = {",
" schema: { type: 'object', properties: { api: { type: 'string' } } },",
"};",
"",
].join("\n"),
"utf8",
);
clearBundledPluginMetadataCache();
const entries = listBundledPluginMetadata({ rootDir: distRoot });
const channelConfigs = entries[0]?.manifest.channelConfigs as
| Record<string, unknown>
| undefined;
expect(channelConfigs?.alpha).toMatchObject({
schema: {
type: "object",
properties: {
manifest: { type: "boolean" },
},
},
label: "Alpha Root Label",
description: "Alpha Root Description",
});
expect(fs.existsSync(markerPath)).toBe(false);
});
});

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { resolveCompatibilityHostVersion } from "../version.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import { normalizePluginsConfig } from "./config-state.js";
import {
inspectPersistedInstalledPluginIndex,
readPersistedInstalledPluginIndexSync,
@@ -128,10 +129,6 @@ function resolveDerivedSnapshotCacheKey(
if (
params.cache === false ||
params.preferPersisted === false ||
params.config ||
params.workspaceDir ||
params.stateDir ||
params.filePath ||
params.pluginIndexFilePath ||
params.installRecords ||
params.candidates ||
@@ -141,11 +138,17 @@ function resolveDerivedSnapshotCacheKey(
return null;
}
const { roots, loadPaths } = resolvePluginCacheInputs({ env });
const normalizedPlugins = normalizePluginsConfig(params.config?.plugins);
const { roots, loadPaths } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
loadPaths: normalizedPlugins.loadPaths,
env,
});
return JSON.stringify({
persistedStore: resolveInstalledPluginIndexStorePath({ env }),
persistedStore: resolveInstalledPluginIndexStorePath(params),
roots,
loadPaths,
policyHash: resolveInstalledPluginIndexPolicyHash(params.config),
hostContractVersion: resolveCompatibilityHostVersion(env),
disablePersisted: env[DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV] ?? "",
disableBundled: env.OPENCLAW_DISABLE_BUNDLED_PLUGINS ?? "",

View File

@@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginCandidate } from "./discovery.js";
import {
readPersistedInstalledPluginIndex,
@@ -544,6 +544,43 @@ describe("plugin registry facade", () => {
]);
});
it("caches config-scoped derived registries when the persisted registry is missing", () => {
const stateDir = makeTempDir();
const workspaceDir = makeTempDir();
const bundledRoot = makeTempDir();
const rootDir = path.join(bundledRoot, "demo");
fs.mkdirSync(rootDir, { recursive: true });
createCandidate(rootDir);
const env = hermeticEnv({ OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot });
const config = { plugins: { entries: { demo: { enabled: true } } } } as const;
const readFileSyncSpy = vi.spyOn(fs, "readFileSync");
const first = loadPluginRegistrySnapshotWithMetadata({
stateDir,
workspaceDir,
config,
env,
});
const manifestReadsAfterFirst = readFileSyncSpy.mock.calls.filter((call) =>
String(call[0]).endsWith("openclaw.plugin.json"),
).length;
const second = loadPluginRegistrySnapshotWithMetadata({
stateDir,
workspaceDir,
config,
env,
});
const manifestReadsAfterSecond = readFileSyncSpy.mock.calls.filter((call) =>
String(call[0]).endsWith("openclaw.plugin.json"),
).length;
expect(first.source).toBe("derived");
expect(second).toBe(first);
expect(manifestReadsAfterFirst).toBeGreaterThan(0);
expect(manifestReadsAfterSecond).toBe(manifestReadsAfterFirst);
});
it("falls back to the derived registry when persisted reads are disabled", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();