From 13d3777cf36d67ad8a6be63eaf3bd8dae37e6272 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 01:44:22 +0100 Subject: [PATCH] fix(plugins): keep config schema on manifest metadata --- src/config/runtime-schema.test.ts | 8 ++ .../bundled-channel-config-metadata.ts | 2 +- src/plugins/bundled-plugin-metadata.test.ts | 78 +++++++++++++++++++ src/plugins/plugin-registry-snapshot.ts | 15 ++-- src/plugins/plugin-registry.test.ts | 39 +++++++++- 5 files changed, 134 insertions(+), 8 deletions(-) diff --git a/src/config/runtime-schema.test.ts b/src/config/runtime-schema.test.ts index 07b681e03e9..9cca0bdc766 100644 --- a/src/config/runtime-schema.test.ts +++ b/src/config/runtime-schema.test.ts @@ -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(); diff --git a/src/plugins/bundled-channel-config-metadata.ts b/src/plugins/bundled-channel-config-metadata.ts index 50ee9942884..8e8b7d4d7e9 100644 --- a/src/plugins/bundled-channel-config-metadata.ts +++ b/src/plugins/bundled-channel-config-metadata.ts @@ -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; diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index fb28829abe0..58e5b71832f 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -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 + | 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); + }); }); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index b4163c32f14..6a459e667e8 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -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 ?? "", diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 603627f6a9e..92a5dfea82b 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -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();