Skip to content

Commit 888f399

Browse files
fix(status): surface should-run plugin drift (#97878)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent beab0ec commit 888f399

7 files changed

Lines changed: 421 additions & 12 deletions
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Covers the shared startup-plan activation config assembly used by both gateway boot
2+
// (prepareGatewayPluginBootstrap) and the /status plugins should-run drift check.
3+
import { describe, expect, it, vi } from "vitest";
4+
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
5+
import type { OpenClawConfig } from "../config/types.openclaw.js";
6+
import { resolveGatewayStartupPluginActivationConfig } from "./plugin-activation-runtime-config.js";
7+
8+
vi.mock("../config/plugin-auto-enable.js", () => ({
9+
applyPluginAutoEnable: vi.fn(),
10+
}));
11+
12+
const applyPluginAutoEnableMock = vi.mocked(applyPluginAutoEnable);
13+
14+
describe("resolveGatewayStartupPluginActivationConfig", () => {
15+
it("auto-enables the source config, then merges activation into the runtime config", () => {
16+
const runtimeConfig = {
17+
plugins: { entries: { keep: { enabled: true, runtimeOnly: 1 } } },
18+
} as unknown as OpenClawConfig;
19+
const sourceConfig = {
20+
plugins: { entries: { keep: { enabled: true } } },
21+
} as unknown as OpenClawConfig;
22+
// Auto-enable runs against the source config and yields an activation config that
23+
// enables an extra plugin; only enable/allow surfaces should carry into runtime config.
24+
applyPluginAutoEnableMock.mockReturnValue({
25+
config: { plugins: { entries: { added: { enabled: true } } } },
26+
} as unknown as ReturnType<typeof applyPluginAutoEnable>);
27+
28+
const result = resolveGatewayStartupPluginActivationConfig({
29+
runtimeConfig,
30+
activationSourceConfig: sourceConfig,
31+
env: {} as NodeJS.ProcessEnv,
32+
});
33+
34+
// Activation is computed from the operator source config, not the runtime config.
35+
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith(
36+
expect.objectContaining({ config: sourceConfig }),
37+
);
38+
// Runtime-only field is preserved; the auto-enabled activation entry is merged in.
39+
expect(result.plugins?.entries?.keep).toEqual({ enabled: true, runtimeOnly: 1 });
40+
expect(result.plugins?.entries?.added).toEqual({ enabled: true });
41+
});
42+
43+
it("passes manifestRegistry and discovery through to auto-enable when provided", () => {
44+
applyPluginAutoEnableMock.mockReturnValue({
45+
config: {},
46+
} as unknown as ReturnType<typeof applyPluginAutoEnable>);
47+
const manifestRegistry = { plugins: [] } as never;
48+
const discovery = { candidates: [] } as never;
49+
50+
resolveGatewayStartupPluginActivationConfig({
51+
runtimeConfig: {} as OpenClawConfig,
52+
activationSourceConfig: {} as OpenClawConfig,
53+
env: {} as NodeJS.ProcessEnv,
54+
manifestRegistry,
55+
discovery,
56+
});
57+
58+
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith(
59+
expect.objectContaining({ manifestRegistry, discovery }),
60+
);
61+
});
62+
});

src/gateway/plugin-activation-runtime-config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// Plugin/channel activation config merge helpers.
22
// Carries activation enablement into runtime config without copying stale state.
3+
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
34
import type { OpenClawConfig } from "../config/types.openclaw.js";
5+
import type { PluginDiscoveryResult } from "../plugins/discovery.js";
6+
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
47
import { isRecord } from "../utils.js";
58

69
// Activation config carries only operator-controlled enable/allow surfaces into
@@ -109,3 +112,27 @@ export function mergeActivationSectionsIntoRuntimeConfig(params: {
109112
runtimeConfig: mergeChannelActivationSections(params),
110113
});
111114
}
115+
116+
// Resolves the effective plugin config the gateway startup *plan* is built from:
117+
// auto-enable the operator activation source, then merge those activation sections into
118+
// the runtime config (so runtime/defaulted fields survive). This is the exact assembly
119+
// `prepareGatewayPluginBootstrap` uses (non-minimal branch); sharing it keeps any consumer
120+
// that recomputes the startup plan — notably the `/status plugins` should-run drift check —
121+
// from drifting away from real gateway boot. Behavior-preserving extraction only.
122+
export function resolveGatewayStartupPluginActivationConfig(params: {
123+
runtimeConfig: OpenClawConfig;
124+
activationSourceConfig: OpenClawConfig;
125+
env: NodeJS.ProcessEnv;
126+
manifestRegistry?: PluginManifestRegistry;
127+
discovery?: PluginDiscoveryResult;
128+
}): OpenClawConfig {
129+
return mergeActivationSectionsIntoRuntimeConfig({
130+
runtimeConfig: params.runtimeConfig,
131+
activationConfig: applyPluginAutoEnable({
132+
config: params.activationSourceConfig,
133+
env: params.env,
134+
...(params.manifestRegistry ? { manifestRegistry: params.manifestRegistry } : {}),
135+
discovery: params.discovery,
136+
}).config,
137+
});
138+
}

src/gateway/server-startup-plugins.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// Runs startup maintenance, loads plugin runtime, and prepares advertised methods.
33
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
44
import { initSubagentRegistry } from "../agents/subagent-registry.js";
5-
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
65
import type { OpenClawConfig } from "../config/types.openclaw.js";
76
import { collectUnregisteredConfiguredMemoryEmbeddingProviders } from "../plugins/channel-plugin-ids.js";
87
import { listRegisteredEmbeddingProviders } from "../plugins/embedding-providers.js";
@@ -12,7 +11,7 @@ import type { PluginRegistry, PluginRegistryParams } from "../plugins/registry-t
1211
import { createEmptyPluginRegistry } from "../plugins/registry.js";
1312
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
1413
import { listCoreGatewayMethodNames } from "./methods/core-descriptors.js";
15-
import { mergeActivationSectionsIntoRuntimeConfig } from "./plugin-activation-runtime-config.js";
14+
import { resolveGatewayStartupPluginActivationConfig } from "./plugin-activation-runtime-config.js";
1615
import { listGatewayMethods } from "./server-methods-list.js";
1716

1817
type GatewayPluginBootstrapLog = {
@@ -90,16 +89,14 @@ export async function prepareGatewayPluginBootstrap(params: {
9089
// defaults injected while loading runtime config; runtime-only plugin config still merges in.
9190
const gatewayPluginConfig = params.minimalTestGateway
9291
? params.cfgAtStart
93-
: mergeActivationSectionsIntoRuntimeConfig({
92+
: resolveGatewayStartupPluginActivationConfig({
9493
runtimeConfig: params.cfgAtStart,
95-
activationConfig: applyPluginAutoEnable({
96-
config: activationSourceConfig,
97-
env: process.env,
98-
...(params.pluginMetadataSnapshot?.manifestRegistry
99-
? { manifestRegistry: params.pluginMetadataSnapshot.manifestRegistry }
100-
: {}),
101-
discovery: params.pluginMetadataSnapshot?.discovery,
102-
}).config,
94+
activationSourceConfig,
95+
env: process.env,
96+
...(params.pluginMetadataSnapshot?.manifestRegistry
97+
? { manifestRegistry: params.pluginMetadataSnapshot.manifestRegistry }
98+
: {}),
99+
discovery: params.pluginMetadataSnapshot?.discovery,
103100
});
104101
const pluginsGloballyDisabled = gatewayPluginConfig.plugins?.enabled === false;
105102
const defaultAgentId = resolveDefaultAgentId(gatewayPluginConfig);
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Installed plugin health snapshot tests cover should-run drift wiring: the eager
2+
// startup plan is read, deferred channel plugins are excluded, and the not-loaded
3+
// remainder surfaces as drift in detailed status.
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { resolveReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
6+
import {
7+
clearRuntimeConfigSnapshot,
8+
setRuntimeConfigSnapshot,
9+
} from "../config/runtime-snapshot.js";
10+
import { resolveGatewayStartupPluginActivationConfig } from "../gateway/plugin-activation-runtime-config.js";
11+
import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js";
12+
import { loadGatewayStartupPluginPlan } from "../plugins/gateway-startup-plugin-ids.js";
13+
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
14+
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
15+
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
16+
import { formatDetailedPluginHealth } from "./status-plugin-health.js";
17+
import { collectInstalledPluginHealthSnapshot } from "./status-plugin-health.runtime.js";
18+
19+
vi.mock("../channels/plugins/read-only.js", () => ({
20+
resolveReadOnlyChannelPluginsForConfig: vi.fn(),
21+
}));
22+
// Keep the installed disk-scan report empty and deterministic so the snapshot's
23+
// plugin records come only from the seeded runtime registry.
24+
vi.mock("../plugins/status.js", async (importOriginal) => {
25+
const actual = await importOriginal<typeof import("../plugins/status.js")>();
26+
const emptyReport = { plugins: [], diagnostics: [] } as unknown as ReturnType<
27+
typeof actual.buildPluginSnapshotReport
28+
>;
29+
return {
30+
...actual,
31+
buildPluginSnapshotReport: vi.fn(() => emptyReport),
32+
buildPluginCompatibilityNotices: vi.fn(() => []),
33+
} as typeof actual;
34+
});
35+
// Override only the startup-plan resolver; preserve every other real export so any
36+
// eager importer in the graph keeps working.
37+
vi.mock("../plugins/gateway-startup-plugin-ids.js", async (importOriginal) => {
38+
const actual = await importOriginal<typeof import("../plugins/gateway-startup-plugin-ids.js")>();
39+
return { ...actual, loadGatewayStartupPluginPlan: vi.fn() } as typeof actual;
40+
});
41+
// The startup-plan activation assembly is the gateway's own shared helper; mock it to
42+
// identity (return the runtime config) so the status wiring stays deterministic. The helper's
43+
// real auto-enable + merge behavior is covered by its own unit test and the gateway startup tests.
44+
vi.mock("../gateway/plugin-activation-runtime-config.js", async (importOriginal) => {
45+
const actual =
46+
await importOriginal<typeof import("../gateway/plugin-activation-runtime-config.js")>();
47+
return {
48+
...actual,
49+
resolveGatewayStartupPluginActivationConfig: vi.fn(
50+
(params: { runtimeConfig: unknown }) =>
51+
params.runtimeConfig as ReturnType<
52+
typeof actual.resolveGatewayStartupPluginActivationConfig
53+
>,
54+
),
55+
} as typeof actual;
56+
});
57+
58+
const resolveReadOnlyChannelPluginsForConfigMock = vi.mocked(
59+
resolveReadOnlyChannelPluginsForConfig,
60+
);
61+
const loadGatewayStartupPluginPlanMock = vi.mocked(loadGatewayStartupPluginPlan);
62+
const resolveGatewayStartupPluginActivationConfigMock = vi.mocked(
63+
resolveGatewayStartupPluginActivationConfig,
64+
);
65+
66+
afterEach(() => {
67+
resolveReadOnlyChannelPluginsForConfigMock.mockReset();
68+
loadGatewayStartupPluginPlanMock.mockReset();
69+
resolveGatewayStartupPluginActivationConfigMock.mockClear();
70+
clearRuntimeConfigSnapshot();
71+
resetPluginRuntimeStateForTest();
72+
resetPluginStateStoreForTests();
73+
});
74+
75+
describe("installed plugin health should-run drift", () => {
76+
it("excludes deferred channel plugins and flags the not-loaded remainder as drift", async () => {
77+
await withStateDirEnv("openclaw-status-should-run-drift-", async () => {
78+
resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({
79+
loadFailures: [],
80+
missingConfiguredChannelIds: [],
81+
} as never);
82+
loadGatewayStartupPluginPlanMock.mockReturnValue({
83+
channelPluginIds: [],
84+
// deferred-chan finishes loading only after listen, so it must not count as drift.
85+
configuredDeferredChannelPluginIds: ["deferred-chan"],
86+
pluginIds: ["deferred-chan", "planned-missing", "runtime-ok"],
87+
});
88+
89+
const registry = createEmptyPluginRegistry();
90+
registry.plugins.push({ id: "runtime-ok", status: "loaded", enabled: true } as never);
91+
setActivePluginRegistry(registry, "runtime-ok", "default", "/tmp/ws");
92+
93+
const rawConfig = {} as never;
94+
const snapshot = await collectInstalledPluginHealthSnapshot({
95+
config: rawConfig,
96+
workspaceDir: "/tmp/ws",
97+
});
98+
99+
// Plan resolved from the auto-enabled effective config with the raw config as the
100+
// activation source — matching gateway boot, so auto-enabled plugins are not missed.
101+
expect(loadGatewayStartupPluginPlanMock).toHaveBeenCalledWith(
102+
expect.objectContaining({ config: rawConfig, activationSourceConfig: rawConfig }),
103+
);
104+
// Deferred channel plugin dropped from the eager should-run set.
105+
expect(snapshot.shouldRunPluginIds).toEqual(["planned-missing", "runtime-ok"]);
106+
107+
const text = formatDetailedPluginHealth(snapshot);
108+
expect(text).toContain("Loaded: 1 (runtime-ok)");
109+
expect(text).toContain("Configured to run but not loaded: 1 (planned-missing)");
110+
expect(text).not.toContain("deferred-chan");
111+
});
112+
});
113+
114+
it("builds the plan via the shared gateway helper using source + runtime config", async () => {
115+
await withStateDirEnv("openclaw-status-should-run-source-cfg-", async () => {
116+
resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({
117+
loadFailures: [],
118+
missingConfiguredChannelIds: [],
119+
} as never);
120+
loadGatewayStartupPluginPlanMock.mockReturnValue({
121+
channelPluginIds: [],
122+
configuredDeferredChannelPluginIds: [],
123+
pluginIds: [],
124+
});
125+
// /status passes the live runtime config; the activation source must be the original
126+
// operator source config, and the effective config must be assembled from the runtime
127+
// config (so runtime/defaulted fields survive) — via the shared gateway-boot helper.
128+
const sourceConfig = { plugins: { entries: {} } } as never;
129+
const runtimeConfig = { plugins: { entries: { extra: { enabled: true } } } } as never;
130+
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
131+
setActivePluginRegistry(createEmptyPluginRegistry(), "empty", "default", "/tmp/ws");
132+
133+
await collectInstalledPluginHealthSnapshot({
134+
config: runtimeConfig,
135+
workspaceDir: "/tmp/ws",
136+
});
137+
138+
// The shared gateway-boot helper assembles the effective config from the runtime config
139+
// with the operator source config as the activation source (not the runtime snapshot).
140+
expect(resolveGatewayStartupPluginActivationConfigMock).toHaveBeenCalledWith(
141+
expect.objectContaining({ runtimeConfig, activationSourceConfig: sourceConfig }),
142+
);
143+
// The plan is resolved from that effective config (here the helper's identity output =
144+
// runtimeConfig) with the operator source config as the activation source.
145+
expect(loadGatewayStartupPluginPlanMock).toHaveBeenCalledWith(
146+
expect.objectContaining({ config: runtimeConfig, activationSourceConfig: sourceConfig }),
147+
);
148+
});
149+
});
150+
151+
it("omits the should-run set entirely when no config is provided", async () => {
152+
await withStateDirEnv("openclaw-status-should-run-no-config-", async () => {
153+
resolveReadOnlyChannelPluginsForConfigMock.mockReturnValue({
154+
loadFailures: [],
155+
missingConfiguredChannelIds: [],
156+
} as never);
157+
setActivePluginRegistry(createEmptyPluginRegistry(), "empty", "default", "/tmp/ws");
158+
159+
const snapshot = await collectInstalledPluginHealthSnapshot({ workspaceDir: "/tmp/ws" });
160+
161+
expect(snapshot.shouldRunPluginIds).toBeUndefined();
162+
expect(loadGatewayStartupPluginPlanMock).not.toHaveBeenCalled();
163+
expect(formatDetailedPluginHealth(snapshot)).not.toContain(
164+
"Configured to run but not loaded:",
165+
);
166+
});
167+
});
168+
});

src/status/status-plugin-health.runtime.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ export async function collectInstalledPluginHealthSnapshot(params: {
208208
report: runtimeRegistry,
209209
}).map(normalizeCompatibilityNotice)
210210
: [];
211-
return mergeStatusPluginHealthSnapshots(
211+
const merged = mergeStatusPluginHealthSnapshots(
212212
{
213213
plugins: report.plugins.map(normalizeSnapshotPlugin),
214214
diagnostics: installedDiagnostics,
@@ -222,4 +222,51 @@ export async function collectInstalledPluginHealthSnapshot(params: {
222222
},
223223
{ ...runtime, compatibilityNotices: runtimeCompatibilityNotices },
224224
);
225+
const shouldRunPluginIds = await resolveEagerShouldRunPluginIds(params);
226+
return shouldRunPluginIds ? { ...merged, shouldRunPluginIds } : merged;
227+
}
228+
229+
// Eager should-run plugin ids from the gateway startup plan, with deferred channel
230+
// plugins removed: their full load completes only after the gateway starts listening,
231+
// so they would be benign mid-startup false positives in the runtime-loaded drift
232+
// comparison. Detailed-status only and resolved lazily so the compact path never pulls
233+
// the startup-plan module. Observer-only: any resolution failure (or absent config)
234+
// degrades to no should-run set rather than breaking /status.
235+
async function resolveEagerShouldRunPluginIds(params: {
236+
config?: OpenClawConfig;
237+
workspaceDir?: string;
238+
}): Promise<string[] | undefined> {
239+
if (!params.config) {
240+
return undefined;
241+
}
242+
try {
243+
const { loadGatewayStartupPluginPlan } =
244+
await import("../plugins/gateway-startup-plugin-ids.js");
245+
const { resolvePluginActivationSourceConfig } =
246+
await import("../plugins/activation-source-config.js");
247+
const { resolveGatewayStartupPluginActivationConfig } =
248+
await import("../gateway/plugin-activation-runtime-config.js");
249+
// Build the should-run plan with the exact assembly gateway boot uses, via the shared
250+
// resolveGatewayStartupPluginActivationConfig helper. params.config is the live runtime
251+
// snapshot; resolvePluginActivationSourceConfig maps it back to the operator source config
252+
// the loader activates against, then the helper auto-enables that source and merges it into
253+
// the runtime config (preserving runtime/defaulted fields). Reusing gateway boot's own helper
254+
// keeps this set from drifting from prepareGatewayPluginBootstrap's plan.
255+
const sourceConfig = resolvePluginActivationSourceConfig({ config: params.config });
256+
const effectiveConfig = resolveGatewayStartupPluginActivationConfig({
257+
runtimeConfig: params.config,
258+
activationSourceConfig: sourceConfig,
259+
env: process.env,
260+
});
261+
const plan = loadGatewayStartupPluginPlan({
262+
config: effectiveConfig,
263+
activationSourceConfig: sourceConfig,
264+
env: process.env,
265+
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
266+
});
267+
const deferred = new Set(plan.configuredDeferredChannelPluginIds);
268+
return plan.pluginIds.filter((id) => !deferred.has(id));
269+
} catch {
270+
return undefined;
271+
}
225272
}

0 commit comments

Comments
 (0)