feat(plugins): report setup descriptor drift (#71194)

This commit is contained in:
Vincent Koc
2026-04-24 11:15:30 -07:00
committed by GitHub
parent 3ffd944e6b
commit 6bc0dc8fb6
5 changed files with 181 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- Gradium: add a bundled text-to-speech provider with voice-note and telephony output support. (#64958) Thanks @LaurentMazare.
- Plugins/setup: honor explicit `setup.requiresRuntime: false` as a descriptor-only setup contract while keeping omitted values on the legacy setup-api fallback path. Thanks @vincentkoc.
- Plugins/setup: report descriptor/runtime drift when setup-api registrations disagree with `setup.providers` or `setup.cliBackends`, without rejecting legacy setup plugins. Thanks @vincentkoc.
- TUI/dependencies: remove direct `cli-highlight` usage from the OpenClaw TUI code-block renderer, keeping themed code coloring without the extra root dependency. Thanks @vincentkoc.
- Diagnostics/OTEL: export run, model-call, and tool-execution diagnostic lifecycle events as OTEL spans without retaining live span state. Thanks @vincentkoc.
- Plugins/activation: expose activation plan reasons and a richer plan API so callers can inspect why a plugin was selected while preserving existing id-list activation behavior. (#70943) Thanks @vincentkoc.

View File

@@ -76,7 +76,9 @@ Setup discovery now prefers descriptor-owned ids such as `setup.providers` and
`requiresRuntime` keeps the legacy setup-api fallback for compatibility. If more
than one discovered plugin claims the same normalized setup provider or CLI
backend id, setup lookup refuses the ambiguous owner instead of relying on
discovery order.
discovery order. When setup runtime does execute, registry diagnostics report
drift between `setup.providers` / `setup.cliBackends` and the providers or CLI
backends registered by setup-api without blocking legacy plugins.
### What the loader caches

View File

@@ -338,6 +338,11 @@ Because setup lookup can execute plugin-owned `setup-api` code, normalized
discovered plugins. Ambiguous ownership fails closed instead of picking a
winner from discovery order.
When setup runtime does execute, setup registry diagnostics report descriptor
drift if `setup-api` registers a provider or CLI backend that the manifest
descriptors do not declare, or if a descriptor has no matching runtime
registration. These diagnostics are additive and do not reject legacy plugins.
### setup.providers reference
| Field | Required | Type | What it means |

View File

@@ -378,10 +378,85 @@ describe("setup-registry getJiti", () => {
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
});
expect(mocks.createJiti).not.toHaveBeenCalled();
});
it("reports setup descriptor drift without rejecting runtime registrations", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
rootDir: pluginRoot,
setup: {
providers: [{ id: "openai" }],
cliBackends: ["codex-cli"],
requiresRuntime: true,
},
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerProvider: (provider: { id: string; label: string; auth: [] }) => void;
registerCliBackend: (backend: { id: string; config: { command: string } }) => void;
}) {
api.registerProvider({
id: "anthropic",
label: "Anthropic",
auth: [],
});
api.registerCliBackend({
id: "claude-cli",
config: { command: "claude" },
});
},
},
});
});
const registry = resolvePluginSetupRegistry({ env: {} });
expect(registry.providers.map((entry) => entry.provider.id)).toEqual(["anthropic"]);
expect(registry.cliBackends.map((entry) => entry.backend.id)).toEqual(["claude-cli"]);
expect(registry.diagnostics).toEqual([
expect.objectContaining({
pluginId: "openai",
code: "setup-descriptor-provider-missing-runtime",
declaredId: "openai",
}),
expect.objectContaining({
pluginId: "openai",
code: "setup-descriptor-provider-runtime-undeclared",
runtimeId: "anthropic",
}),
expect.objectContaining({
pluginId: "openai",
code: "setup-descriptor-cli-backend-missing-runtime",
declaredId: "codex-cli",
}),
expect.objectContaining({
pluginId: "openai",
code: "setup-descriptor-cli-backend-runtime-undeclared",
runtimeId: "claude-cli",
}),
]);
});
it("does not report drift when setup descriptors match runtime registrations", () => {
mockOpenAiCliBackendRegistration({
requiresRuntime: true,
});
expect(resolvePluginSetupRegistry({ env: {} }).diagnostics).toEqual([]);
});
it("does not load setup-api modules from the current working directory", () => {
const pluginRoot = makeTempDir();
const workspaceRoot = makeTempDir();

View File

@@ -46,11 +46,26 @@ type SetupAutoEnableProbeEntry = {
probe: PluginSetupAutoEnableProbe;
};
export type PluginSetupRegistryDiagnosticCode =
| "setup-descriptor-provider-missing-runtime"
| "setup-descriptor-provider-runtime-undeclared"
| "setup-descriptor-cli-backend-missing-runtime"
| "setup-descriptor-cli-backend-runtime-undeclared";
export type PluginSetupRegistryDiagnostic = {
pluginId: string;
code: PluginSetupRegistryDiagnosticCode;
declaredId?: string;
runtimeId?: string;
message: string;
};
type PluginSetupRegistry = {
providers: SetupProviderEntry[];
cliBackends: SetupCliBackendEntry[];
configMigrations: SetupConfigMigrationEntry[];
autoEnableProbes: SetupAutoEnableProbeEntry[];
diagnostics: PluginSetupRegistryDiagnostic[];
};
type SetupAutoEnableReason = {
@@ -372,6 +387,75 @@ function findUniqueSetupManifestOwner(params: {
return matches.length === 1 ? matches[0] : undefined;
}
function mapNormalizedIds(ids: readonly string[]): Map<string, string> {
const mapped = new Map<string, string>();
for (const id of ids) {
const normalized = normalizeProviderId(id);
if (!normalized || mapped.has(normalized)) {
continue;
}
mapped.set(normalized, id);
}
return mapped;
}
function pushSetupDescriptorDriftDiagnostics(params: {
record: PluginManifestRecord;
providers: readonly ProviderPlugin[];
cliBackends: readonly CliBackendPlugin[];
diagnostics: PluginSetupRegistryDiagnostic[];
}): void {
const declaredProviderIds = params.record.setup?.providers?.map((entry) => entry.id);
if (declaredProviderIds) {
for (const declaredId of declaredProviderIds) {
if (!params.providers.some((provider) => matchesProvider(provider, declaredId))) {
params.diagnostics.push({
pluginId: params.record.id,
code: "setup-descriptor-provider-missing-runtime",
declaredId,
message: `setup.providers declares "${declaredId}" but setup runtime did not register a matching provider.`,
});
}
}
for (const provider of params.providers) {
if (!declaredProviderIds.some((declaredId) => matchesProvider(provider, declaredId))) {
params.diagnostics.push({
pluginId: params.record.id,
code: "setup-descriptor-provider-runtime-undeclared",
runtimeId: provider.id,
message: `setup runtime registered provider "${provider.id}" but setup.providers does not declare it.`,
});
}
}
}
const declaredCliBackendIds = params.record.setup?.cliBackends;
if (declaredCliBackendIds) {
const declaredCliBackends = mapNormalizedIds(declaredCliBackendIds);
const runtimeCliBackends = mapNormalizedIds(params.cliBackends.map((backend) => backend.id));
for (const [normalized, declaredId] of declaredCliBackends) {
if (!runtimeCliBackends.has(normalized)) {
params.diagnostics.push({
pluginId: params.record.id,
code: "setup-descriptor-cli-backend-missing-runtime",
declaredId,
message: `setup.cliBackends declares "${declaredId}" but setup runtime did not register a matching CLI backend.`,
});
}
}
for (const [normalized, runtimeId] of runtimeCliBackends) {
if (!declaredCliBackends.has(normalized)) {
params.diagnostics.push({
pluginId: params.record.id,
code: "setup-descriptor-cli-backend-runtime-undeclared",
runtimeId,
message: `setup runtime registered CLI backend "${runtimeId}" but setup.cliBackends does not declare it.`,
});
}
}
}
}
export function resolvePluginSetupRegistry(params?: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
@@ -397,6 +481,7 @@ export function resolvePluginSetupRegistry(params?: {
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
} satisfies PluginSetupRegistry;
setCachedSetupValue(setupRegistryCache, cacheKey, empty);
return empty;
@@ -406,6 +491,7 @@ export function resolvePluginSetupRegistry(params?: {
const cliBackends: SetupCliBackendEntry[] = [];
const configMigrations: SetupConfigMigrationEntry[] = [];
const autoEnableProbes: SetupAutoEnableProbeEntry[] = [];
const diagnostics: PluginSetupRegistryDiagnostic[] = [];
const providerKeys = new Set<string>();
const cliBackendKeys = new Set<string>();
@@ -423,6 +509,8 @@ export function resolvePluginSetupRegistry(params?: {
continue;
}
const recordProviders: ProviderPlugin[] = [];
const recordCliBackends: CliBackendPlugin[] = [];
const api = buildSetupPluginApi({
record,
setupSource: setupRegistration.setupSource,
@@ -437,6 +525,7 @@ export function resolvePluginSetupRegistry(params?: {
pluginId: record.id,
provider,
});
recordProviders.push(provider);
},
registerCliBackend(backend) {
const key = `${record.id}:${normalizeProviderId(backend.id)}`;
@@ -448,6 +537,7 @@ export function resolvePluginSetupRegistry(params?: {
pluginId: record.id,
backend,
});
recordCliBackends.push(backend);
},
registerConfigMigration(migrate) {
configMigrations.push({
@@ -473,6 +563,12 @@ export function resolvePluginSetupRegistry(params?: {
} catch {
continue;
}
pushSetupDescriptorDriftDiagnostics({
record,
providers: recordProviders,
cliBackends: recordCliBackends,
diagnostics,
});
}
const registry = {
@@ -480,6 +576,7 @@ export function resolvePluginSetupRegistry(params?: {
cliBackends,
configMigrations,
autoEnableProbes,
diagnostics,
} satisfies PluginSetupRegistry;
setCachedSetupValue(setupRegistryCache, cacheKey, registry);
return registry;