From d062f8130b1a89a92a30e4748f988477585e2b2f Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 28 Apr 2026 05:56:23 +0100 Subject: [PATCH] feat: warn on implicit startup plugin compatibility --- CHANGELOG.md | 1 + docs/plugins/compatibility.md | 2 + docs/plugins/manifest.md | 4 +- src/plugins/registry-types.ts | 2 + src/plugins/status.test-helpers.ts | 46 ++++++++---- src/plugins/status.test.ts | 116 ++++++++++++++++++++++++++++- src/plugins/status.ts | 13 +++- 7 files changed, 165 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 597250b99cb..1c9a6d8852f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (`openclaw-plugin-yuanbao`) in the official channel catalog, contract suites, and community plugin docs, with a new `docs/channels/yuanbao.md` quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay. - Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh. - Plugins/startup: migrate bundled plugin manifests to explicit `activation.onStartup` declarations so Gateway startup imports only the bundled plugins that intentionally register startup-time runtime surfaces. Thanks @shakkernerd. +- Plugins/startup: add plugin compatibility warnings for deprecated implicit startup loading so authors can migrate to explicit `activation.onStartup` metadata. Thanks @shakkernerd. - Plugins/runtime: load bundled agent tool-result middleware from manifest contracts on demand so tokenjuice stays startup-lazy without losing Pi/Codex tool-output compaction. Thanks @shakkernerd. - Plugins/startup: add explicit `activation.onStartup` metadata so plugins can declare Gateway startup import behavior while the deprecated implicit sidecar fallback remains for legacy plugins. Thanks @shakkernerd. - Gateway/startup: reuse lookup-table plugin manifests when loading startup plugins so Gateway boot avoids rebuilding plugin discovery and manifest metadata. Thanks @shakkernerd. diff --git a/docs/plugins/compatibility.md b/docs/plugins/compatibility.md index e2b50c6d1eb..2f08bd886b9 100644 --- a/docs/plugins/compatibility.md +++ b/docs/plugins/compatibility.md @@ -135,6 +135,8 @@ Current compatibility records include: - legacy channel route key and comparable-target helper aliases while plugins move to `openclaw/plugin-sdk/channel-route` - activation hints that are being replaced by manifest contribution ownership +- deprecated implicit startup sidecar loading for plugins that have not declared + `activation.onStartup` - `setup-api` runtime fallback while setup descriptors move to cold `setup.requiresRuntime: false` metadata - provider `discovery` hooks while provider catalog hooks move to diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index f2456aafbbe..794d6bfcbfa 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -264,7 +264,9 @@ run during Gateway startup. Set it to `false` when the plugin is inert at startup and should load only from narrower triggers. Omitting `onStartup` keeps the deprecated legacy implicit startup sidecar fallback for plugins with no static capability metadata; future versions may stop startup-loading those -plugins unless they declare `activation.onStartup: true`. +plugins unless they declare `activation.onStartup: true`. Plugin status and +compatibility reports warn with `legacy-implicit-startup-sidecar` when a plugin +still relies on that fallback. ```json { diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 5c757037456..491b20bce71 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -9,6 +9,7 @@ import type { AgentToolResultMiddlewareRuntime, } from "./agent-tool-result-middleware-types.js"; import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; +import type { PluginCompatCode } from "./compat/registry.js"; import type { PluginActivationSource } from "./config-state.js"; import type { PluginAgentEventSubscriptionRegistration, @@ -328,6 +329,7 @@ export type PluginRecord = { explicitlyEnabled?: boolean; activated?: boolean; imported?: boolean; + compat?: readonly PluginCompatCode[]; activationSource?: PluginActivationSource; activationReason?: string; status: "loaded" | "disabled" | "error"; diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts index 625357bc4a9..8029caab4d1 100644 --- a/src/plugins/status.test-helpers.ts +++ b/src/plugins/status.test-helpers.ts @@ -5,29 +5,43 @@ import type { PluginHookName } from "./types.js"; export const LEGACY_BEFORE_AGENT_START_MESSAGE = "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work."; +export const LEGACY_IMPLICIT_STARTUP_SIDECAR_MESSAGE = + "relies on deprecated implicit startup loading; add activation.onStartup: true for startup work or activation.onStartup: false for startup-lazy plugins."; export const HOOK_ONLY_MESSAGE = "is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet."; export function createCompatibilityNotice( params: Pick, ): PluginCompatibilityNotice { - if (params.code === "legacy-before-agent-start") { - return { - pluginId: params.pluginId, - code: params.code, - compatCode: "legacy-before-agent-start", - severity: "warn", - message: LEGACY_BEFORE_AGENT_START_MESSAGE, - }; + switch (params.code) { + case "legacy-before-agent-start": + return { + pluginId: params.pluginId, + code: params.code, + compatCode: "legacy-before-agent-start", + severity: "warn", + message: LEGACY_BEFORE_AGENT_START_MESSAGE, + }; + case "legacy-implicit-startup-sidecar": + return { + pluginId: params.pluginId, + code: params.code, + compatCode: "legacy-implicit-startup-sidecar", + severity: "warn", + message: LEGACY_IMPLICIT_STARTUP_SIDECAR_MESSAGE, + }; + case "hook-only": + return { + pluginId: params.pluginId, + code: params.code, + compatCode: "hook-only-plugin-shape", + severity: "info", + message: HOOK_ONLY_MESSAGE, + }; } - - return { - pluginId: params.pluginId, - code: params.code, - compatCode: "hook-only-plugin-shape", - severity: "info", - message: HOOK_ONLY_MESSAGE, - }; + const unsupportedCode: never = params.code; + void unsupportedCode; + throw new Error("unsupported compatibility notice code"); } export function createPluginRecord( diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 989c7d904d7..9b39a5bd439 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -7,11 +7,14 @@ import { createTypedHook, HOOK_ONLY_MESSAGE, LEGACY_BEFORE_AGENT_START_MESSAGE, + LEGACY_IMPLICIT_STARTUP_SIDECAR_MESSAGE, } from "./status.test-helpers.js"; const loadConfigMock = vi.fn(); const loadOpenClawPluginsMock = vi.fn(); const loadPluginMetadataRegistrySnapshotMock = vi.fn(); +const loadPluginRegistrySnapshotWithMetadataMock = vi.fn(); +const loadPluginManifestRegistryForInstalledIndexMock = vi.fn(); const applyPluginAutoEnableMock = vi.fn(); const resolveBundledProviderCompatPluginIdsMock = vi.fn(); const withBundledPluginAllowlistCompatMock = vi.fn(); @@ -19,6 +22,7 @@ const withBundledPluginEnablementCompatMock = vi.fn(); const listImportedBundledPluginFacadeIdsMock = vi.fn(); const listImportedRuntimePluginIdsMock = vi.fn(); let buildPluginSnapshotReport: typeof import("./status.js").buildPluginSnapshotReport; +let buildPluginRegistrySnapshotReport: typeof import("./status.js").buildPluginRegistrySnapshotReport; let buildPluginDiagnosticsReport: typeof import("./status.js").buildPluginDiagnosticsReport; let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport; let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports; @@ -45,6 +49,16 @@ vi.mock("./runtime/metadata-registry-loader.js", () => ({ loadPluginMetadataRegistrySnapshotMock(...args), })); +vi.mock("./plugin-registry.js", () => ({ + loadPluginRegistrySnapshotWithMetadata: (...args: unknown[]) => + loadPluginRegistrySnapshotWithMetadataMock(...args), +})); + +vi.mock("./manifest-registry-installed.js", () => ({ + loadPluginManifestRegistryForInstalledIndex: (...args: unknown[]) => + loadPluginManifestRegistryForInstalledIndexMock(...args), +})); + vi.mock("./providers.js", () => ({ resolveBundledProviderCompatPluginIds: (...args: unknown[]) => resolveBundledProviderCompatPluginIdsMock(...args), @@ -95,6 +109,23 @@ function setSinglePluginLoadResult( }); } +function createInstalledPluginIndexSnapshot( + plugins: Array>, +): Record { + return { + version: 1, + warning: "test", + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, + policyHash: "test", + generatedAtMs: 0, + installRecords: {}, + plugins, + diagnostics: [], + }; +} + function expectInspectReport( pluginId: string, ): NonNullable> { @@ -321,6 +352,7 @@ describe("plugin status reports", () => { buildPluginDiagnosticsReport, buildPluginCompatibilityWarnings, buildPluginInspectReport, + buildPluginRegistrySnapshotReport, buildPluginSnapshotReport, formatPluginCompatibilityNotice, summarizePluginCompatibility, @@ -331,6 +363,8 @@ describe("plugin status reports", () => { loadConfigMock.mockReset(); loadOpenClawPluginsMock.mockReset(); loadPluginMetadataRegistrySnapshotMock.mockReset(); + loadPluginRegistrySnapshotWithMetadataMock.mockReset(); + loadPluginManifestRegistryForInstalledIndexMock.mockReset(); applyPluginAutoEnableMock.mockReset(); resolveBundledProviderCompatPluginIdsMock.mockReset(); withBundledPluginAllowlistCompatMock.mockReset(); @@ -338,6 +372,15 @@ describe("plugin status reports", () => { listImportedBundledPluginFacadeIdsMock.mockReset(); listImportedRuntimePluginIdsMock.mockReset(); loadConfigMock.mockReturnValue({}); + loadPluginRegistrySnapshotWithMetadataMock.mockReturnValue({ + snapshot: createInstalledPluginIndexSnapshot([]), + source: "derived", + diagnostics: [], + }); + loadPluginManifestRegistryForInstalledIndexMock.mockReturnValue({ + plugins: [], + diagnostics: [], + }); applyPluginAutoEnableMock.mockImplementation((params: { config: unknown }) => ({ config: params.config, changes: [], @@ -393,6 +436,41 @@ describe("plugin status reports", () => { }); }); + it("carries installed-index compatibility metadata into registry snapshot reports", () => { + loadPluginRegistrySnapshotWithMetadataMock.mockReturnValue({ + snapshot: createInstalledPluginIndexSnapshot([ + { + pluginId: "legacy-sidecar", + manifestPath: "/tmp/legacy-sidecar/openclaw.plugin.json", + manifestHash: "manifest-hash", + rootDir: "/tmp/legacy-sidecar", + origin: "workspace", + enabled: true, + startup: { + sidecar: true, + memory: false, + deferConfiguredChannelFullLoadUntilAfterListen: false, + agentHarnesses: [], + }, + compat: ["legacy-implicit-startup-sidecar"], + }, + ]), + source: "derived", + diagnostics: [], + }); + loadPluginManifestRegistryForInstalledIndexMock.mockReturnValue({ + plugins: [{ id: "legacy-sidecar", name: "Legacy Sidecar" }], + diagnostics: [], + }); + + const report = buildPluginRegistrySnapshotReport({ config: {} }); + + expect(report.plugins[0]).toMatchObject({ + id: "legacy-sidecar", + compat: ["legacy-implicit-startup-sidecar"], + }); + }); + it("uses a metadata snapshot load for snapshot reports", () => { buildPluginSnapshotReport({ config: {}, workspaceDir: "/workspace" }); @@ -755,6 +833,38 @@ describe("plugin status reports", () => { }); }); + it("builds compatibility warnings for deprecated implicit startup sidecar metadata", () => { + setSinglePluginLoadResult( + createPluginRecord({ + id: "legacy-sidecar", + name: "Legacy Sidecar", + compat: ["legacy-implicit-startup-sidecar"], + }), + ); + + expectCompatibilityOutput({ + notices: [ + createCompatibilityNotice({ + pluginId: "legacy-sidecar", + code: "legacy-implicit-startup-sidecar", + }), + ], + warnings: [`legacy-sidecar ${LEGACY_IMPLICIT_STARTUP_SIDECAR_MESSAGE}`], + }); + }); + + it("does not warn when explicit startup-lazy metadata avoids legacy startup compatibility", () => { + setSinglePluginLoadResult( + createPluginRecord({ + id: "modern-startup-lazy", + name: "Modern Startup Lazy", + compat: [], + }), + ); + + expectNoCompatibilityWarnings(); + }); + it("returns no compatibility warnings for modern capability plugins", () => { setSinglePluginLoadResult( createPluginRecord({ @@ -819,10 +929,14 @@ describe("plugin status reports", () => { expect( summarizePluginCompatibility([ notice, + createCompatibilityNotice({ + pluginId: "legacy-plugin", + code: "legacy-implicit-startup-sidecar", + }), createCompatibilityNotice({ pluginId: "legacy-plugin", code: "hook-only" }), ]), ).toEqual({ - noticeCount: 2, + noticeCount: 3, pluginCount: 1, }); }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 49687ff1b45..3a9a44bee32 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -50,7 +50,7 @@ export type { PluginCapabilityKind, PluginInspectShape } from "./inspect-shape.j export type PluginCompatibilityNotice = { pluginId: string; - code: "legacy-before-agent-start" | "hook-only"; + code: "legacy-before-agent-start" | "legacy-implicit-startup-sidecar" | "hook-only"; compatCode: PluginCompatCode; severity: "warn" | "info"; message: string; @@ -121,6 +121,16 @@ function buildCompatibilityNoticesForInspect( "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.", }); } + if (inspect.plugin.compat?.includes("legacy-implicit-startup-sidecar")) { + warnings.push({ + pluginId: inspect.plugin.id, + code: "legacy-implicit-startup-sidecar", + compatCode: "legacy-implicit-startup-sidecar", + severity: "warn", + message: + "relies on deprecated implicit startup loading; add activation.onStartup: true for startup work or activation.onStartup: false for startup-lazy plugins.", + }); + } if (inspect.shape === "hook-only") { warnings.push({ pluginId: inspect.plugin.id, @@ -177,6 +187,7 @@ function buildPluginRecordFromInstalledIndex( rootDir: plugin.rootDir, origin: plugin.origin, enabled: plugin.enabled, + compat: plugin.compat, syntheticAuthRefs: [...(plugin.syntheticAuthRefs ?? manifest?.syntheticAuthRefs ?? [])], status: plugin.enabled ? "loaded" : "disabled", toolNames: [],