fix(status): surface should-run plugin drift (#97878)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Masato Hoshino
2026-06-29 10:46:36 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent beab0ecb02
commit 888f399499
7 changed files with 421 additions and 12 deletions
@@ -0,0 +1,62 @@
// Covers the shared startup-plan activation config assembly used by both gateway boot
// (prepareGatewayPluginBootstrap) and the /status plugins should-run drift check.
import { describe, expect, it, vi } from "vitest";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveGatewayStartupPluginActivationConfig } from "./plugin-activation-runtime-config.js";
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: vi.fn(),
}));
const applyPluginAutoEnableMock = vi.mocked(applyPluginAutoEnable);
describe("resolveGatewayStartupPluginActivationConfig", () => {
it("auto-enables the source config, then merges activation into the runtime config", () => {
const runtimeConfig = {
plugins: { entries: { keep: { enabled: true, runtimeOnly: 1 } } },
} as unknown as OpenClawConfig;
const sourceConfig = {
plugins: { entries: { keep: { enabled: true } } },
} as unknown as OpenClawConfig;
// Auto-enable runs against the source config and yields an activation config that
// enables an extra plugin; only enable/allow surfaces should carry into runtime config.
applyPluginAutoEnableMock.mockReturnValue({
config: { plugins: { entries: { added: { enabled: true } } } },
} as unknown as ReturnType<typeof applyPluginAutoEnable>);
const result = resolveGatewayStartupPluginActivationConfig({
runtimeConfig,
activationSourceConfig: sourceConfig,
env: {} as NodeJS.ProcessEnv,
});
// Activation is computed from the operator source config, not the runtime config.
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith(
expect.objectContaining({ config: sourceConfig }),
);
// Runtime-only field is preserved; the auto-enabled activation entry is merged in.
expect(result.plugins?.entries?.keep).toEqual({ enabled: true, runtimeOnly: 1 });
expect(result.plugins?.entries?.added).toEqual({ enabled: true });
});
it("passes manifestRegistry and discovery through to auto-enable when provided", () => {
applyPluginAutoEnableMock.mockReturnValue({
config: {},
} as unknown as ReturnType<typeof applyPluginAutoEnable>);
const manifestRegistry = { plugins: [] } as never;
const discovery = { candidates: [] } as never;
resolveGatewayStartupPluginActivationConfig({
runtimeConfig: {} as OpenClawConfig,
activationSourceConfig: {} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
manifestRegistry,
discovery,
});
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith(
expect.objectContaining({ manifestRegistry, discovery }),
);
});
});
@@ -1,6 +1,9 @@
// Plugin/channel activation config merge helpers.
// Carries activation enablement into runtime config without copying stale state.
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginDiscoveryResult } from "../plugins/discovery.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { isRecord } from "../utils.js";
// Activation config carries only operator-controlled enable/allow surfaces into
@@ -109,3 +112,27 @@ export function mergeActivationSectionsIntoRuntimeConfig(params: {
runtimeConfig: mergeChannelActivationSections(params),
});
}
// Resolves the effective plugin config the gateway startup *plan* is built from:
// auto-enable the operator activation source, then merge those activation sections into
// the runtime config (so runtime/defaulted fields survive). This is the exact assembly
// `prepareGatewayPluginBootstrap` uses (non-minimal branch); sharing it keeps any consumer
// that recomputes the startup plan — notably the `/status plugins` should-run drift check —
// from drifting away from real gateway boot. Behavior-preserving extraction only.
export function resolveGatewayStartupPluginActivationConfig(params: {
runtimeConfig: OpenClawConfig;
activationSourceConfig: OpenClawConfig;
env: NodeJS.ProcessEnv;
manifestRegistry?: PluginManifestRegistry;
discovery?: PluginDiscoveryResult;
}): OpenClawConfig {
return mergeActivationSectionsIntoRuntimeConfig({
runtimeConfig: params.runtimeConfig,
activationConfig: applyPluginAutoEnable({
config: params.activationSourceConfig,
env: params.env,
...(params.manifestRegistry ? { manifestRegistry: params.manifestRegistry } : {}),
discovery: params.discovery,
}).config,
});
}
+8 -11
View File
@@ -2,7 +2,6 @@
// Runs startup maintenance, loads plugin runtime, and prepares advertised methods.
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { initSubagentRegistry } from "../agents/subagent-registry.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { collectUnregisteredConfiguredMemoryEmbeddingProviders } from "../plugins/channel-plugin-ids.js";
import { listRegisteredEmbeddingProviders } from "../plugins/embedding-providers.js";
@@ -12,7 +11,7 @@ import type { PluginRegistry, PluginRegistryParams } from "../plugins/registry-t
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { listCoreGatewayMethodNames } from "./methods/core-descriptors.js";
import { mergeActivationSectionsIntoRuntimeConfig } from "./plugin-activation-runtime-config.js";
import { resolveGatewayStartupPluginActivationConfig } from "./plugin-activation-runtime-config.js";
import { listGatewayMethods } from "./server-methods-list.js";
type GatewayPluginBootstrapLog = {
@@ -90,16 +89,14 @@ export async function prepareGatewayPluginBootstrap(params: {
// defaults injected while loading runtime config; runtime-only plugin config still merges in.
const gatewayPluginConfig = params.minimalTestGateway
? params.cfgAtStart
: mergeActivationSectionsIntoRuntimeConfig({
: resolveGatewayStartupPluginActivationConfig({
runtimeConfig: params.cfgAtStart,
activationConfig: applyPluginAutoEnable({
config: activationSourceConfig,
env: process.env,
...(params.pluginMetadataSnapshot?.manifestRegistry
? { manifestRegistry: params.pluginMetadataSnapshot.manifestRegistry }
: {}),
discovery: params.pluginMetadataSnapshot?.discovery,
}).config,
activationSourceConfig,
env: process.env,
...(params.pluginMetadataSnapshot?.manifestRegistry
? { manifestRegistry: params.pluginMetadataSnapshot.manifestRegistry }
: {}),
discovery: params.pluginMetadataSnapshot?.discovery,
});
const pluginsGloballyDisabled = gatewayPluginConfig.plugins?.enabled === false;
const defaultAgentId = resolveDefaultAgentId(gatewayPluginConfig);
@@ -0,0 +1,168 @@
// Installed plugin health snapshot tests cover should-run drift wiring: the eager
// startup plan is read, deferred channel plugins are excluded, and the not-loaded
// remainder surfaces as drift in detailed status.
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../config/runtime-snapshot.js";
import { resolveGatewayStartupPluginActivationConfig } from "../gateway/plugin-activation-runtime-config.js";
import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js";
import { loadGatewayStartupPluginPlan } from "../plugins/gateway-startup-plugin-ids.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
import { formatDetailedPluginHealth } from "./status-plugin-health.js";
import { collectInstalledPluginHealthSnapshot } from "./status-plugin-health.runtime.js";
vi.mock("../channels/plugins/read-only.js", () => ({
resolveReadOnlyChannelPluginsForConfig: vi.fn(),
}));
// Keep the installed disk-scan report empty and deterministic so the snapshot's
// plugin records come only from the seeded runtime registry.
vi.mock("../plugins/status.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/status.js")>();
const emptyReport = { plugins: [], diagnostics: [] } as unknown as ReturnType<
typeof actual.buildPluginSnapshotReport
>;
return {
...actual,
buildPluginSnapshotReport: vi.fn(() => emptyReport),
buildPluginCompatibilityNotices: vi.fn(() => []),
} as typeof actual;
});
// Override only the startup-plan resolver; preserve every other real export so any
// eager importer in the graph keeps working.
vi.mock("../plugins/gateway-startup-plugin-ids.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/gateway-startup-plugin-ids.js")>();
return { ...actual, loadGatewayStartupPluginPlan: vi.fn() } as typeof actual;
});
// The startup-plan activation assembly is the gateway's own shared helper; mock it to
// identity (return the runtime config) so the status wiring stays deterministic. The helper's
// real auto-enable + merge behavior is covered by its own unit test and the gateway startup tests.
vi.mock("../gateway/plugin-activation-runtime-config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../gateway/plugin-activation-runtime-config.js")>();
return {
...actual,
resolveGatewayStartupPluginActivationConfig: vi.fn(
(params: { runtimeConfig: unknown }) =>
params.runtimeConfig as ReturnType<
typeof actual.resolveGatewayStartupPluginActivationConfig
>,
),
} as typeof actual;
});
const resolveReadOnlyChannelPluginsForConfigMock = vi.mocked(
resolveReadOnlyChannelPluginsForConfig,
);
const loadGatewayStartupPluginPlanMock = vi.mocked(loadGatewayStartupPluginPlan);
const resolveGatewayStartupPluginActivationConfigMock = vi.mocked(
resolveGatewayStartupPluginActivationConfig,
);
afterEach(() => {
resolveReadOnlyChannelPluginsForConfigMock.mockReset();
loadGatewayStartupPluginPlanMock.mockReset();
resolveGatewayStartupPluginActivationConfigMock.mockClear();
clearRuntimeConfigSnapshot();
resetPluginRuntimeStateForTest();
resetPluginStateStoreForTests();
});
describe("installed plugin health should-run drift", () => {
it("excludes deferred channel plugins and flags the not-loaded remainder as drift", async () => {
await withStateDirEnv("openclaw-status-should-run-drift-", async () => {
resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({
loadFailures: [],
missingConfiguredChannelIds: [],
} as never);
loadGatewayStartupPluginPlanMock.mockReturnValue({
channelPluginIds: [],
// deferred-chan finishes loading only after listen, so it must not count as drift.
configuredDeferredChannelPluginIds: ["deferred-chan"],
pluginIds: ["deferred-chan", "planned-missing", "runtime-ok"],
});
const registry = createEmptyPluginRegistry();
registry.plugins.push({ id: "runtime-ok", status: "loaded", enabled: true } as never);
setActivePluginRegistry(registry, "runtime-ok", "default", "/tmp/ws");
const rawConfig = {} as never;
const snapshot = await collectInstalledPluginHealthSnapshot({
config: rawConfig,
workspaceDir: "/tmp/ws",
});
// Plan resolved from the auto-enabled effective config with the raw config as the
// activation source — matching gateway boot, so auto-enabled plugins are not missed.
expect(loadGatewayStartupPluginPlanMock).toHaveBeenCalledWith(
expect.objectContaining({ config: rawConfig, activationSourceConfig: rawConfig }),
);
// Deferred channel plugin dropped from the eager should-run set.
expect(snapshot.shouldRunPluginIds).toEqual(["planned-missing", "runtime-ok"]);
const text = formatDetailedPluginHealth(snapshot);
expect(text).toContain("Loaded: 1 (runtime-ok)");
expect(text).toContain("Configured to run but not loaded: 1 (planned-missing)");
expect(text).not.toContain("deferred-chan");
});
});
it("builds the plan via the shared gateway helper using source + runtime config", async () => {
await withStateDirEnv("openclaw-status-should-run-source-cfg-", async () => {
resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({
loadFailures: [],
missingConfiguredChannelIds: [],
} as never);
loadGatewayStartupPluginPlanMock.mockReturnValue({
channelPluginIds: [],
configuredDeferredChannelPluginIds: [],
pluginIds: [],
});
// /status passes the live runtime config; the activation source must be the original
// operator source config, and the effective config must be assembled from the runtime
// config (so runtime/defaulted fields survive) — via the shared gateway-boot helper.
const sourceConfig = { plugins: { entries: {} } } as never;
const runtimeConfig = { plugins: { entries: { extra: { enabled: true } } } } as never;
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
setActivePluginRegistry(createEmptyPluginRegistry(), "empty", "default", "/tmp/ws");
await collectInstalledPluginHealthSnapshot({
config: runtimeConfig,
workspaceDir: "/tmp/ws",
});
// The shared gateway-boot helper assembles the effective config from the runtime config
// with the operator source config as the activation source (not the runtime snapshot).
expect(resolveGatewayStartupPluginActivationConfigMock).toHaveBeenCalledWith(
expect.objectContaining({ runtimeConfig, activationSourceConfig: sourceConfig }),
);
// The plan is resolved from that effective config (here the helper's identity output =
// runtimeConfig) with the operator source config as the activation source.
expect(loadGatewayStartupPluginPlanMock).toHaveBeenCalledWith(
expect.objectContaining({ config: runtimeConfig, activationSourceConfig: sourceConfig }),
);
});
});
it("omits the should-run set entirely when no config is provided", async () => {
await withStateDirEnv("openclaw-status-should-run-no-config-", async () => {
resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({
loadFailures: [],
missingConfiguredChannelIds: [],
} as never);
setActivePluginRegistry(createEmptyPluginRegistry(), "empty", "default", "/tmp/ws");
const snapshot = await collectInstalledPluginHealthSnapshot({ workspaceDir: "/tmp/ws" });
expect(snapshot.shouldRunPluginIds).toBeUndefined();
expect(loadGatewayStartupPluginPlanMock).not.toHaveBeenCalled();
expect(formatDetailedPluginHealth(snapshot)).not.toContain(
"Configured to run but not loaded:",
);
});
});
});
+48 -1
View File
@@ -208,7 +208,7 @@ export async function collectInstalledPluginHealthSnapshot(params: {
report: runtimeRegistry,
}).map(normalizeCompatibilityNotice)
: [];
return mergeStatusPluginHealthSnapshots(
const merged = mergeStatusPluginHealthSnapshots(
{
plugins: report.plugins.map(normalizeSnapshotPlugin),
diagnostics: installedDiagnostics,
@@ -222,4 +222,51 @@ export async function collectInstalledPluginHealthSnapshot(params: {
},
{ ...runtime, compatibilityNotices: runtimeCompatibilityNotices },
);
const shouldRunPluginIds = await resolveEagerShouldRunPluginIds(params);
return shouldRunPluginIds ? { ...merged, shouldRunPluginIds } : merged;
}
// Eager should-run plugin ids from the gateway startup plan, with deferred channel
// plugins removed: their full load completes only after the gateway starts listening,
// so they would be benign mid-startup false positives in the runtime-loaded drift
// comparison. Detailed-status only and resolved lazily so the compact path never pulls
// the startup-plan module. Observer-only: any resolution failure (or absent config)
// degrades to no should-run set rather than breaking /status.
async function resolveEagerShouldRunPluginIds(params: {
config?: OpenClawConfig;
workspaceDir?: string;
}): Promise<string[] | undefined> {
if (!params.config) {
return undefined;
}
try {
const { loadGatewayStartupPluginPlan } =
await import("../plugins/gateway-startup-plugin-ids.js");
const { resolvePluginActivationSourceConfig } =
await import("../plugins/activation-source-config.js");
const { resolveGatewayStartupPluginActivationConfig } =
await import("../gateway/plugin-activation-runtime-config.js");
// Build the should-run plan with the exact assembly gateway boot uses, via the shared
// resolveGatewayStartupPluginActivationConfig helper. params.config is the live runtime
// snapshot; resolvePluginActivationSourceConfig maps it back to the operator source config
// the loader activates against, then the helper auto-enables that source and merges it into
// the runtime config (preserving runtime/defaulted fields). Reusing gateway boot's own helper
// keeps this set from drifting from prepareGatewayPluginBootstrap's plan.
const sourceConfig = resolvePluginActivationSourceConfig({ config: params.config });
const effectiveConfig = resolveGatewayStartupPluginActivationConfig({
runtimeConfig: params.config,
activationSourceConfig: sourceConfig,
env: process.env,
});
const plan = loadGatewayStartupPluginPlan({
config: effectiveConfig,
activationSourceConfig: sourceConfig,
env: process.env,
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
});
const deferred = new Set(plan.configuredDeferredChannelPluginIds);
return plan.pluginIds.filter((id) => !deferred.has(id));
} catch {
return undefined;
}
}
+72
View File
@@ -323,4 +323,76 @@ describe("plugin health status formatting", () => {
expect(text).toContain("Loaded: 2 (active-ok, pinned-only)");
expect(text).not.toContain("Installed (not active):");
});
it("flags should-run plugins missing from the runtime-loaded set as drift", () => {
const text = formatDetailedPluginHealth({
plugins: [
{ id: "runtime-ok", status: "loaded", enabled: true },
// Planned for eager startup but never loaded at runtime: drift.
{ id: "planned-missing", status: "loaded", enabled: true },
// Installed/discovered but not in the startup plan: neutral inventory.
{ id: "not-planned-idle", status: "loaded", enabled: true },
],
diagnostics: [],
contextEngineQuarantines: [],
runtimeLoadedPluginIds: ["runtime-ok"],
shouldRunPluginIds: ["planned-missing", "runtime-ok"],
});
expect(text).toContain("Loaded: 1 (runtime-ok)");
expect(text).toContain("Configured to run but not loaded: 1 (planned-missing)");
// Not-planned stays neutral; the drift id never appears in both inventory lines.
expect(text).toContain("Installed (not active): 1 (not-planned-idle)");
});
it("reports no drift when every should-run plugin is runtime-loaded", () => {
const text = formatDetailedPluginHealth({
plugins: [
{ id: "a", status: "loaded", enabled: true },
{ id: "b", status: "loaded", enabled: true },
],
diagnostics: [],
contextEngineQuarantines: [],
runtimeLoadedPluginIds: ["a", "b"],
shouldRunPluginIds: ["a", "b"],
});
expect(text).toContain("Loaded: 2 (a, b)");
expect(text).not.toContain("Configured to run but not loaded:");
});
it("does not re-report should-run plugins already shown as error or disabled", () => {
const text = formatDetailedPluginHealth({
plugins: [
{ id: "runtime-ok", status: "loaded", enabled: true },
{ id: "broken", status: "error", enabled: true, failurePhase: "load", error: "boom" },
{ id: "off", status: "disabled", enabled: false },
],
diagnostics: [],
contextEngineQuarantines: [],
runtimeLoadedPluginIds: ["runtime-ok"],
// Both broken and off are in the startup plan but already explained by their
// own records, so neither should surface as drift.
shouldRunPluginIds: ["broken", "off", "runtime-ok"],
});
expect(text).toContain("- broken [load]: boom");
expect(text).toContain("Disabled: 1");
expect(text).not.toContain("Configured to run but not loaded:");
});
it("omits the drift line when the should-run set is absent (back-compat)", () => {
const text = formatDetailedPluginHealth({
plugins: [
{ id: "runtime-ok", status: "loaded", enabled: true },
{ id: "installed-idle", status: "loaded", enabled: true },
],
diagnostics: [],
contextEngineQuarantines: [],
runtimeLoadedPluginIds: ["runtime-ok"],
});
expect(text).toContain("Installed (not active): 1 (installed-idle)");
expect(text).not.toContain("Configured to run but not loaded:");
});
});
+36
View File
@@ -65,6 +65,12 @@ export type StatusPluginHealthSnapshot = {
// load). Absent on hand-built/compact snapshots, where detailed rendering falls
// back to the merged status filter.
runtimeLoadedPluginIds?: string[];
// Eager should-run plugin ids from the gateway startup plan (deferred channel
// plugins already excluded). Paired with runtimeLoadedPluginIds, it lets detailed
// status flag desired-vs-observed drift: a plugin the gateway planned to start that
// is not in the runtime-loaded set. Absent on compact/hand-built snapshots, where
// no drift line is rendered (back-compat).
shouldRunPluginIds?: string[];
};
/** Keeps the first record per key; later duplicates are dropped. */
@@ -254,10 +260,30 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot)
const runtimeLoadedIds = snapshot.runtimeLoadedPluginIds;
const runtimeLoaded = runtimeLoadedIds ? new Set(runtimeLoadedIds) : undefined;
const loaded = (runtimeLoadedIds ?? statusLoaded.map((plugin) => plugin.id)).toSorted(byLocale);
// Desired-vs-observed drift: ids the gateway's eager startup plan says should run
// but that are absent from the runtime-loaded set and not already explained by an
// error/disabled record (those surface in their own sections). Computed only when
// both the should-run set and runtime provenance are present, so compact/hand-built
// snapshots render exactly as before.
const explainedPluginIds = new Set(
snapshot.plugins
.filter((plugin) => plugin.status === "error" || plugin.status === "disabled")
.map((plugin) => plugin.id),
);
const shouldRunNotLoaded =
snapshot.shouldRunPluginIds && runtimeLoaded
? snapshot.shouldRunPluginIds
.filter((id) => !runtimeLoaded.has(id) && !explainedPluginIds.has(id))
.toSorted(byLocale)
: [];
const shouldRunNotLoadedSet = new Set(shouldRunNotLoaded);
const installedNotActive = runtimeLoaded
? statusLoaded
.filter((plugin) => !runtimeLoaded.has(plugin.id))
.map((plugin) => plugin.id)
// Drift ids are reported on their own line below; keep them out of the
// neutral "Installed (not active)" inventory so each id appears once.
.filter((id) => !shouldRunNotLoadedSet.has(id))
.toSorted(byLocale)
: [];
const disabled = snapshot.plugins.filter((plugin) => plugin.status === "disabled").length;
@@ -296,6 +322,16 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot)
);
}
if (shouldRunNotLoaded.length > 0) {
// Planned for eager startup but missing from the live runtime-loaded set (e.g.,
// config changed since the gateway started, or a planned plugin did not come up).
// Observer-only signal, distinct from neutral inventory; not an error chip and
// not counted in the compact line.
lines.push(
`Configured to run but not loaded: ${shouldRunNotLoaded.length} (${formatPluginList(shouldRunNotLoaded, 8)})`,
);
}
if (errors.length > 0) {
lines.push(
`Errors: ${errors.length}`,