diff --git a/docs/concepts/agent-runtimes.md b/docs/concepts/agent-runtimes.md index df384a3010e3..b6f09665cd74 100644 --- a/docs/concepts/agent-runtimes.md +++ b/docs/concepts/agent-runtimes.md @@ -214,9 +214,9 @@ canonical subscription `github-copilot` provider and is **never** selected by } ``` -The harness claims its provider, runtime, CLI session key, and auth profile -prefix in `extensions/copilot/doctor-contract-api.ts`, which `openclaw doctor` -auto-loads. For configuration, auth, transcript mirroring, compaction, the +The plugin manifest declares the harness provider, runtime, CLI session key, +and auth profile prefix without requiring `openclaw doctor` to load plugin +code. For configuration, auth, transcript mirroring, compaction, the declarative doctor contract, and the broader PI vs Codex vs Copilot SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot). diff --git a/docs/plugins/copilot.md b/docs/plugins/copilot.md index 12a59fdee0fa..6f0dfbe87a7c 100755 --- a/docs/plugins/copilot.md +++ b/docs/plugins/copilot.md @@ -260,13 +260,13 @@ keeps `/btw` behavior identical to other non-Codex runtimes. ## Doctor -`extensions/copilot/doctor-contract-api.ts` is auto-loaded by -`src/plugins/doctor-contract-registry.ts`. It contributes: +The Copilot plugin contributes doctor repair metadata through its manifest and +doctor contract: - An empty `legacyConfigRules` (no retired fields yet). - A no-op `normalizeCompatibilityConfig` (kept so future field retirements have a stable in-tree home). -- One `sessionRouteStateOwners` entry: provider `github-copilot`, runtime +- Its manifest declares one `sessionRouteStateOwners` entry: provider `github-copilot`, runtime `copilot`, CLI session key `copilot`, auth profile prefix `github-copilot:`. ## Limitations diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 01daea77929a..70ee0b049e82 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -162,6 +162,8 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m | `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. | | `activation` | No | `object` | Cheap activation planner metadata for startup, provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. | | `setup` | No | `object` | Cheap setup/onboarding descriptors that discovery and setup surfaces can inspect without loading plugin runtime. | +| `doctorContract` | No | `object` | Declares which dynamic doctor-contract surfaces the plugin artifact exports so doctor loads only relevant modules. | +| `sessionRouteStateOwners` | No | `object[]` | Static session-route ownership for doctor cleanup. Each entry declares an `id`, `label`, and optional `providerIds`, `runtimeIds`, `cliSessionKeys`, and `authProfilePrefixes`. | | `qaRunners` | No | `object[]` | Cheap QA runner descriptors used by the shared `openclaw qa` host before plugin runtime loads. | | `dashboard` | No | `object` | Dashboard widget data bindings and action verbs. Each entry is validated against a Gateway method registered by this plugin with the required read or write scope. See [dashboard reference](#dashboard-reference). | | `mcpServers` | No | `Record` | Static MCP server definitions contributed while this plugin is enabled. Relative command arguments and working directories resolve from the plugin root. Operator `mcp.servers` entries override or disable definitions with the same name. See [MCP server reference](#mcp-server-reference). | @@ -181,6 +183,18 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m | `version` | No | `string` | Informational plugin version. | | `uiHints` | No | `Record` | UI labels, placeholders, and sensitivity hints for config fields. | +Prefer top-level `sessionRouteStateOwners` for static doctor ownership. The +older `doctorContract.sessionRouteStateOwners: true` declaration plus a +`sessionRouteStateOwners` export from `doctor-contract-api` remains supported +for external plugins, but is deprecated. When the manifest field is present, +OpenClaw uses it without loading the doctor-contract module. Removal plan: +remove the module fallback in OpenClaw 2027.1 after the external-plugin +migration window. + +Set `doctorContract.configRepair: true` when the doctor-contract module exports +non-empty `legacyConfigRules`, a `normalizeCompatibilityConfig` function, or +both. One declaration covers the complete config-repair artifact. + ## MCP server reference `mcpServers` lets a native plugin ship an MCP server, including an MCP App, without requiring operators to duplicate its static process definition in `openclaw.json`: diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 3b1cc755a320..7ac425f04d3d 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -83,6 +83,23 @@ External-plugin compatibility work follows this order: 6. Remove only after the announced migration window, usually in a major release. +### Channel state migration declarations + +Channel plugins should declare `doctorContract.stateMigrations: true` in +`openclaw.plugin.json` and export `stateMigrations` from their doctor-contract +artifact. Plan-based migrations can use +`definePluginDoctorMigrationFromPlans(...)` from +`openclaw/plugin-sdk/runtime-doctor` to preserve existing move, copy, preview, +and plugin-state import behavior. + +The setup-entry `legacyStateMigrations` option and feature flag, +`setupFeatures.legacyStateMigrations`, +`BundledChannelLegacyStateMigrationDetector`, and +`ChannelPlugin.lifecycle.detectLegacyStateMigrations` remain supported through +one doctor-pipeline adapter for external plugins, but are deprecated. Removal +plan: remove that adapter after OpenClaw 2027.1 only when a published-plugin +reader sweep finds no remaining users. + ### AuthStorage SQLite migration `AuthStorage.forAgent(agentDir)` is the canonical provider-keyed session SDK diff --git a/extensions/anthropic/doctor-contract-api.ts b/extensions/anthropic/doctor-contract-api.ts deleted file mode 100644 index 6621c57a0bd0..000000000000 --- a/extensions/anthropic/doctor-contract-api.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Doctor contract metadata for Anthropic and Claude CLI state. It declares - * session/auth ownership so doctor cleanup can route stale state correctly. - */ -import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor-migrations"; - -/** Anthropic currently has no legacy config migrations. */ -export const legacyConfigRules = []; - -/** Session-route ownership metadata for Anthropic API and Claude CLI sessions. */ -export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [ - { - id: "anthropic", - label: "Anthropic", - providerIds: ["anthropic", "claude-cli"], - runtimeIds: ["claude-cli"], - cliSessionKeys: ["claude-cli"], - authProfilePrefixes: ["anthropic:", "claude-cli:"], - }, -]; diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index dc810520d6cd..6d6eb379419e 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -1,8 +1,16 @@ { "id": "anthropic", - "doctorContract": { - "sessionRouteStateOwners": true - }, + "doctorContract": {}, + "sessionRouteStateOwners": [ + { + "id": "anthropic", + "label": "Anthropic", + "providerIds": ["anthropic", "claude-cli"], + "runtimeIds": ["claude-cli"], + "cliSessionKeys": ["claude-cli"], + "authProfilePrefixes": ["anthropic:", "claude-cli:"] + } + ], "name": "Anthropic", "description": "Anthropic models, Claude CLI, and native Claude session catalog.", "icon": "https://cdn.simpleicons.org/anthropic", diff --git a/extensions/clickclack/openclaw.plugin.json b/extensions/clickclack/openclaw.plugin.json index 77cb71446637..ff667511341f 100644 --- a/extensions/clickclack/openclaw.plugin.json +++ b/extensions/clickclack/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "clickclack", "doctorContract": { - "normalizeCompatibilityConfig": true + "configRepair": true }, "activation": { "onStartup": false diff --git a/extensions/codex/doctor-contract-api.ts b/extensions/codex/doctor-contract-api.ts index 1bd4661b6322..9792d7bd3cbc 100644 --- a/extensions/codex/doctor-contract-api.ts +++ b/extensions/codex/doctor-contract-api.ts @@ -1,9 +1,7 @@ /** - * Doctor contract hooks for Codex plugin config migrations and session-route - * ownership warnings. + * Doctor contract hooks for Codex plugin config and state migrations. */ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor-migrations"; type LegacyConfigRule = { path: string[]; @@ -137,16 +135,4 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): }; } -/** Session/auth ownership metadata used by doctor route-state checks. */ -export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [ - { - id: "codex", - label: "Codex", - providerIds: ["codex", "codex-cli", "openai-codex"], - runtimeIds: ["codex", "codex-cli"], - cliSessionKeys: ["codex-cli"], - authProfilePrefixes: ["codex:", "codex-cli:", "openai-codex:"], - }, -]; - export { stateMigrations } from "./src/migration/session-binding-sidecars.js"; diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index fd046c3cfdf4..17be2e132fd7 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -1,11 +1,19 @@ { "id": "codex", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true, - "sessionRouteStateOwners": true, + "configRepair": true, "stateMigrations": true }, + "sessionRouteStateOwners": [ + { + "id": "codex", + "label": "Codex", + "providerIds": ["codex", "codex-cli", "openai-codex"], + "runtimeIds": ["codex", "codex-cli"], + "cliSessionKeys": ["codex-cli"], + "authProfilePrefixes": ["codex:", "codex-cli:", "openai-codex:"] + } + ], "name": "Codex", "description": "Codex app-server harness and native session catalog.", "contracts": { diff --git a/extensions/copilot/doctor-contract-api.test.ts b/extensions/copilot/doctor-contract-api.test.ts index f08aefa0e065..2bcc828fa750 100755 --- a/extensions/copilot/doctor-contract-api.test.ts +++ b/extensions/copilot/doctor-contract-api.test.ts @@ -1,17 +1,8 @@ // Copilot tests cover doctor contract api plugin behavior. -import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; -import { - legacyConfigRules, - normalizeCompatibilityConfig, - sessionRouteStateOwners, -} from "./doctor-contract-api.js"; +import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js"; describe("copilot doctor contract", () => { - function requireSessionRouteOwner() { - return expectDefined(sessionRouteStateOwners[0], "Copilot session route state owner"); - } - it("has no legacy config rules at MVP (no retired fields exist yet)", () => { expect(legacyConfigRules).toEqual([]); }); @@ -26,23 +17,4 @@ describe("copilot doctor contract", () => { expect(result.config).toBe(cfg); expect(result.changes).toEqual([]); }); - - it("declares exactly one session route state owner for copilot", () => { - expect(sessionRouteStateOwners).toHaveLength(1); - const owner = requireSessionRouteOwner(); - expect(owner.id).toBe("copilot"); - expect(owner.label).toBe("GitHub Copilot agent runtime"); - }); - - it("claims the subscription Copilot providers (matches attempt.ts SUPPORTED_PROVIDERS)", () => { - const owner = requireSessionRouteOwner(); - expect(owner.providerIds).toEqual(["github-copilot"]); - }); - - it("claims the copilot runtime, session key, and auth profile prefix", () => { - const owner = requireSessionRouteOwner(); - expect(owner.runtimeIds).toEqual(["copilot"]); - expect(owner.cliSessionKeys).toEqual(["copilot"]); - expect(owner.authProfilePrefixes).toEqual(["github-copilot:"]); - }); }); diff --git a/extensions/copilot/doctor-contract-api.ts b/extensions/copilot/doctor-contract-api.ts index 26dc2b2b6ff4..d4388e9dd181 100755 --- a/extensions/copilot/doctor-contract-api.ts +++ b/extensions/copilot/doctor-contract-api.ts @@ -2,19 +2,15 @@ * Doctor contract for the copilot extension. * * Mirrors {@link ../codex/doctor-contract-api.ts} so `openclaw doctor` - * can: - * - Reason about which session-state belongs to this extension - * (sessionRouteStateOwners) for cleanup of stale state across - * runtime swaps. - * - Detect retired config fields and migrate them + * can detect retired config fields and migrate them * (legacyConfigRules + normalizeCompatibilityConfig). No retired * fields exist for copilot yet; the array is empty by design * and normalizeCompatibilityConfig is a structural no-op so - * future retirements have a stable in-tree home. + * future retirements have a stable in-tree home. Session-route ownership + * is static manifest metadata in openclaw.plugin.json. */ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor-migrations"; type LegacyConfigRule = { path: string[]; @@ -30,25 +26,3 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): } { return { config: cfg, changes: [] }; } - -/** - * Session-state ownership claim for the copilot agent runtime. - * - * - id / label: Identify the extension in doctor output. - * - providerIds: The subscription Copilot providers. - * - runtimeIds: Our harness id (matches harness.ts `id` field). - * - cliSessionKeys: Session keys this harness writes; doctor uses - * this when pruning stale CLI session state. - * - authProfilePrefixes: Conventional prefix for any auth profile - * created/consumed by this extension. - */ -export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [ - { - id: "copilot", - label: "GitHub Copilot agent runtime", - providerIds: ["github-copilot"], - runtimeIds: ["copilot"], - cliSessionKeys: ["copilot"], - authProfilePrefixes: ["github-copilot:"], - }, -]; diff --git a/extensions/copilot/openclaw.plugin.json b/extensions/copilot/openclaw.plugin.json index 437278296dfb..af49cdb35538 100644 --- a/extensions/copilot/openclaw.plugin.json +++ b/extensions/copilot/openclaw.plugin.json @@ -1,9 +1,18 @@ { "id": "copilot", "doctorContract": { - "normalizeCompatibilityConfig": true, - "sessionRouteStateOwners": true + "configRepair": true }, + "sessionRouteStateOwners": [ + { + "id": "copilot", + "label": "GitHub Copilot agent runtime", + "providerIds": ["github-copilot"], + "runtimeIds": ["copilot"], + "cliSessionKeys": ["copilot"], + "authProfilePrefixes": ["github-copilot:"] + } + ], "name": "GitHub Copilot agent runtime", "description": "Registers the GitHub Copilot agent runtime.", "icon": "https://cdn.simpleicons.org/githubcopilot", diff --git a/extensions/cua-computer/openclaw.plugin.json b/extensions/cua-computer/openclaw.plugin.json index d853668201bd..ebd553f821f8 100644 --- a/extensions/cua-computer/openclaw.plugin.json +++ b/extensions/cua-computer/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "cua-computer", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "activation": { "onStartup": true diff --git a/extensions/deepinfra/openclaw.plugin.json b/extensions/deepinfra/openclaw.plugin.json index 095bdeb75963..f32186081d37 100644 --- a/extensions/deepinfra/openclaw.plugin.json +++ b/extensions/deepinfra/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "deepinfra", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "activation": { "onStartup": false diff --git a/extensions/discord/doctor-contract-api.ts b/extensions/discord/doctor-contract-api.ts index 9936674f3888..f94f559369d9 100644 --- a/extensions/discord/doctor-contract-api.ts +++ b/extensions/discord/doctor-contract-api.ts @@ -1,2 +1,13 @@ // Discord API module exposes the plugin public contract. +import { definePluginDoctorMigrationFromPlans } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { detectDiscordLegacyStateMigrations } from "./src/monitor/model-picker-preferences-migrations.js"; + export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; + +export const stateMigrations = [ + definePluginDoctorMigrationFromPlans({ + id: "discord-legacy-state", + label: "Discord legacy state", + resolvePlans: detectDiscordLegacyStateMigrations, + }), +]; diff --git a/extensions/discord/legacy-state-migrations-api.ts b/extensions/discord/legacy-state-migrations-api.ts deleted file mode 100644 index fe35fbc14e4f..000000000000 --- a/extensions/discord/legacy-state-migrations-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Discord API module exposes the plugin public contract. -export { detectDiscordLegacyStateMigrations } from "./src/monitor/model-picker-preferences-migrations.js"; diff --git a/extensions/discord/openclaw.plugin.json b/extensions/discord/openclaw.plugin.json index 557c22113f9e..e45d29c2953a 100644 --- a/extensions/discord/openclaw.plugin.json +++ b/extensions/discord/openclaw.plugin.json @@ -1,8 +1,8 @@ { "id": "discord", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true, + "stateMigrations": true }, "name": "Discord", "description": "OpenClaw Discord channel plugin for channels, DMs, commands, and app events.", diff --git a/extensions/discord/package.json b/extensions/discord/package.json index f06f4994e12b..3977224cbbbc 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -36,9 +36,6 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", - "setupFeatures": { - "legacyStateMigrations": true - }, "channel": { "id": "discord", "configuredState": { diff --git a/extensions/discord/setup-entry.test.ts b/extensions/discord/setup-entry.test.ts index bd4b3420246f..52bc0a10d27a 100644 --- a/extensions/discord/setup-entry.test.ts +++ b/extensions/discord/setup-entry.test.ts @@ -2,25 +2,10 @@ import { describe, expect, it } from "vitest"; import setupEntry from "./setup-entry.js"; -type LegacyStateMigrationsApi = typeof import("./legacy-state-migrations-api.js"); - -const migrationDetector = - (() => []) satisfies LegacyStateMigrationsApi["detectDiscordLegacyStateMigrations"]; -const setupEntryLoadOptions = { - createLoaderForTest: (() => (specifier: string) => { - expect(specifier).toMatch(/[\\/]legacy-state-migrations-api\.[jt]s$/u); - return { - detectDiscordLegacyStateMigrations: migrationDetector, - } satisfies Pick; - }) as never, -}; - describe("discord setup entry", () => { - it("resolves the legacy state migration detector through the setup entry", () => { + it("keeps legacy state migrations on the doctor contract", () => { expect(setupEntry.kind).toBe("bundled-channel-setup-entry"); - expect(setupEntry.features).toEqual({ legacyStateMigrations: true }); - expect(setupEntry.loadLegacyStateMigrationDetector?.(setupEntryLoadOptions)).toBe( - migrationDetector, - ); + expect(setupEntry.features).toBeUndefined(); + expect(setupEntry.loadLegacyStateMigrationDetector).toBeUndefined(); }); }); diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts index e6d84acd7625..f0396a8337a8 100644 --- a/extensions/discord/setup-entry.ts +++ b/extensions/discord/setup-entry.ts @@ -3,15 +3,8 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr export default defineBundledChannelSetupEntry({ importMetaUrl: import.meta.url, - features: { - legacyStateMigrations: true, - }, plugin: { specifier: "./setup-plugin-api.js", exportName: "discordSetupPlugin", }, - legacyStateMigrations: { - specifier: "./legacy-state-migrations-api.js", - exportName: "detectDiscordLegacyStateMigrations", - }, }); diff --git a/extensions/discord/src/monitor/model-picker-preferences-migrations.test.ts b/extensions/discord/src/monitor/model-picker-preferences-migrations.test.ts index 5974b281b848..27908bfec29d 100644 --- a/extensions/discord/src/monitor/model-picker-preferences-migrations.test.ts +++ b/extensions/discord/src/monitor/model-picker-preferences-migrations.test.ts @@ -2,7 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { buildLegacyMigrationPreview } from "openclaw/plugin-sdk/runtime-doctor-migrations"; import { afterEach, describe, expect, it } from "vitest"; +import { stateMigrations } from "../../doctor-contract-api.js"; import { detectDiscordLegacyStateMigrations } from "./model-picker-preferences-migrations.js"; const tempDirs: string[] = []; @@ -248,6 +250,7 @@ describe("Discord model picker preference migration", () => { const stateDir = await makeStateDir(); const discordDir = path.join(stateDir, "discord"); await fs.mkdir(discordDir, { recursive: true }); + await fs.writeFile(path.join(discordDir, "command-deploy-cache.json"), "{}"); await fs.writeFile( path.join(discordDir, "model-picker-preferences.json"), JSON.stringify({ version: 1, entries: {} }), @@ -265,11 +268,50 @@ describe("Discord model picker preference migration", () => { stateDir, }), ); + if (!plans) { + throw new Error("expected migration plans"); + } - expect(plans?.map((plan) => plan.label)).toEqual([ - "Discord model picker preferences", - "Discord thread bindings", + expect( + plans?.map((plan) => ({ + kind: plan.kind, + label: plan.label, + sourcePath: plan.sourcePath, + targetPath: plan.targetPath, + namespace: plan.kind === "plugin-state-import" ? plan.namespace : null, + })), + ).toEqual([ + { + kind: "plugin-state-import", + label: "Discord command deployment cache", + sourcePath: path.join(discordDir, "command-deploy-cache.json"), + targetPath: "plugin state:command-deploy-hashes", + namespace: "command-deploy-hashes", + }, + { + kind: "plugin-state-import", + label: "Discord model picker preferences", + sourcePath: path.join(discordDir, "model-picker-preferences.json"), + targetPath: "plugin state:model-picker-preferences", + namespace: "model-picker-preferences", + }, + { + kind: "plugin-state-import", + label: "Discord thread bindings", + sourcePath: path.join(discordDir, "thread-bindings.json"), + targetPath: "plugin state:thread-bindings", + namespace: "thread-bindings", + }, ]); + await expect( + stateMigrations[0]?.detectLegacyState({ + config: {}, + env: {}, + stateDir, + oauthDir: path.join(stateDir, "credentials"), + context: { openPluginStateKeyedStore: () => ({}) } as never, + }), + ).resolves.toEqual({ preview: plans.map(buildLegacyMigrationPreview) }); }); it("archives valid empty legacy thread bindings after an empty import", async () => { diff --git a/extensions/elevenlabs/openclaw.plugin.json b/extensions/elevenlabs/openclaw.plugin.json index 261f01f56087..07cfbd6b3702 100644 --- a/extensions/elevenlabs/openclaw.plugin.json +++ b/extensions/elevenlabs/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "elevenlabs", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "icon": "https://cdn.simpleicons.org/elevenlabs", "activation": { diff --git a/extensions/feishu/openclaw.plugin.json b/extensions/feishu/openclaw.plugin.json index 02811bb66b91..6fac776ee0fb 100644 --- a/extensions/feishu/openclaw.plugin.json +++ b/extensions/feishu/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "feishu", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "name": "Feishu/Lark", "description": "OpenClaw Feishu/Lark channel plugin for chats and workplace tools (community maintained by @m1heng).", diff --git a/extensions/google-meet/openclaw.plugin.json b/extensions/google-meet/openclaw.plugin.json index a7554a4e1800..eb67e4634f00 100644 --- a/extensions/google-meet/openclaw.plugin.json +++ b/extensions/google-meet/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "google-meet", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "name": "Google Meet", "description": "OpenClaw Google Meet participant plugin for joining calls through Chrome or Twilio transports.", diff --git a/extensions/google/doctor-contract-api.ts b/extensions/google/doctor-contract-api.ts deleted file mode 100644 index 7c0b44d77ec3..000000000000 --- a/extensions/google/doctor-contract-api.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Google API module exposes the plugin public contract. -import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor-migrations"; - -export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [ - { - id: "google", - label: "Google", - providerIds: ["google", "google-antigravity", "google-gemini-cli", "google-vertex"], - runtimeIds: ["google-gemini-cli"], - cliSessionKeys: ["google-gemini-cli", "gemini-cli"], - authProfilePrefixes: [ - "google:", - "google-antigravity:", - "google-gemini-cli:", - "google-vertex:", - "gemini-cli:", - ], - }, -]; diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 0397bb5a07ad..9aad07784f35 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -1,8 +1,22 @@ { "id": "google", - "doctorContract": { - "sessionRouteStateOwners": true - }, + "doctorContract": {}, + "sessionRouteStateOwners": [ + { + "id": "google", + "label": "Google", + "providerIds": ["google", "google-antigravity", "google-gemini-cli", "google-vertex"], + "runtimeIds": ["google-gemini-cli"], + "cliSessionKeys": ["google-gemini-cli", "gemini-cli"], + "authProfilePrefixes": [ + "google:", + "google-antigravity:", + "google-gemini-cli:", + "google-vertex:", + "gemini-cli:" + ] + } + ], "icon": "https://cdn.simpleicons.org/google", "activation": { "onStartup": false diff --git a/extensions/googlechat/openclaw.plugin.json b/extensions/googlechat/openclaw.plugin.json index 40502bc22fb1..dcc64dff80f1 100644 --- a/extensions/googlechat/openclaw.plugin.json +++ b/extensions/googlechat/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "googlechat", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "name": "Google Chat", "description": "OpenClaw Google Chat channel plugin for spaces and direct messages.", diff --git a/extensions/imessage/doctor-contract-api.ts b/extensions/imessage/doctor-contract-api.ts index 2ad1b6acfdd3..b3eaeddd8393 100644 --- a/extensions/imessage/doctor-contract-api.ts +++ b/extensions/imessage/doctor-contract-api.ts @@ -4,8 +4,12 @@ import type { ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { + defineChannelAliasMigration, + definePluginDoctorMigrationFromPlans, +} from "openclaw/plugin-sdk/runtime-doctor-migrations"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { detectIMessageLegacyStateMigrations } from "./src/state-migrations.js"; // Disabled `channels.imessage.catchup` blocks are retired. Enabled blocks stay // as a compatibility contract: older configs that opted into replay still get @@ -102,3 +106,11 @@ export function normalizeCompatibilityConfig({ } return { config: aliases.config, changes }; } + +export const stateMigrations = [ + definePluginDoctorMigrationFromPlans({ + id: "imessage-legacy-state", + label: "iMessage legacy state", + resolvePlans: detectIMessageLegacyStateMigrations, + }), +]; diff --git a/extensions/imessage/legacy-state-migrations-api.ts b/extensions/imessage/legacy-state-migrations-api.ts deleted file mode 100644 index 5c8e6f0868d3..000000000000 --- a/extensions/imessage/legacy-state-migrations-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Imessage API module exposes the plugin public contract. -export { detectIMessageLegacyStateMigrations } from "./src/state-migrations.js"; diff --git a/extensions/imessage/openclaw.plugin.json b/extensions/imessage/openclaw.plugin.json index 8aa9f85d734d..024e56c47bba 100644 --- a/extensions/imessage/openclaw.plugin.json +++ b/extensions/imessage/openclaw.plugin.json @@ -1,8 +1,8 @@ { "id": "imessage", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true, + "stateMigrations": true }, "icon": "https://cdn.simpleicons.org/imessage", "activation": { diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 94fc639437a3..d41a4287863c 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -29,8 +29,7 @@ ], "setupEntry": "./setup-entry.ts", "setupFeatures": { - "configPromotion": true, - "legacyStateMigrations": true + "configPromotion": true }, "channel": { "id": "imessage", diff --git a/extensions/imessage/setup-entry.ts b/extensions/imessage/setup-entry.ts index edc0f58ed80c..d7566b6fea3b 100644 --- a/extensions/imessage/setup-entry.ts +++ b/extensions/imessage/setup-entry.ts @@ -3,15 +3,8 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr export default defineBundledChannelSetupEntry({ importMetaUrl: import.meta.url, - features: { - legacyStateMigrations: true, - }, plugin: { specifier: "./api.js", exportName: "imessageSetupPlugin", }, - legacyStateMigrations: { - specifier: "./legacy-state-migrations-api.js", - exportName: "detectIMessageLegacyStateMigrations", - }, }); diff --git a/extensions/imessage/src/state-migrations.test.ts b/extensions/imessage/src/state-migrations.test.ts index 2c5115fd2525..cbea99de3ef1 100644 --- a/extensions/imessage/src/state-migrations.test.ts +++ b/extensions/imessage/src/state-migrations.test.ts @@ -3,7 +3,9 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { buildLegacyMigrationPreview } from "openclaw/plugin-sdk/runtime-doctor-migrations"; import { afterEach, describe, expect, it } from "vitest"; +import { stateMigrations } from "../doctor-contract-api.js"; import { resolveIMessageCatchupCursorKey } from "./monitor/catchup.js"; import { detectIMessageLegacyStateMigrations } from "./state-migrations.js"; @@ -56,6 +58,8 @@ describe("detectIMessageLegacyStateMigrations", () => { updatedAt: 1_700_000_000_123, }), ); + const orphanPath = path.join(imsgDir, "catchup", "removed__123456789abc.json"); + fs.writeFileSync(orphanPath, JSON.stringify({ lastSeenMs: 1, lastSeenRowid: 2 })); const plans = await detectIMessageLegacyStateMigrations({ cfg: { channels: { imessage: { enabled: true } } } as never, @@ -65,6 +69,7 @@ describe("detectIMessageLegacyStateMigrations", () => { expect(plans.map((plan) => plan.label)).toEqual([ "iMessage catchup cursor", + "iMessage orphan catchup cursor", "iMessage reply short-id counter", "iMessage reply short-id cache", "iMessage sent-echo dedupe cache", @@ -85,9 +90,64 @@ describe("detectIMessageLegacyStateMigrations", () => { expect(plan.cleanupWhenEmpty).toBe(true); } const entries = await plan.readEntries(); - expect(entries).toHaveLength(1); + expect(entries).toHaveLength(plan.label === "iMessage orphan catchup cursor" ? 0 : 1); } + expect( + plans.map((plan) => ({ + kind: plan.kind, + label: plan.label, + sourcePath: plan.sourcePath, + targetPath: plan.targetPath, + namespace: plan.kind === "plugin-state-import" ? plan.namespace : null, + })), + ).toEqual([ + { + kind: "plugin-state-import", + label: "iMessage catchup cursor", + sourcePath: path.join(imsgDir, "catchup", "default__37a8eec1ce19.json"), + targetPath: "plugin state:imessage.catchup-cursors", + namespace: "imessage.catchup-cursors", + }, + { + kind: "plugin-state-import", + label: "iMessage orphan catchup cursor", + sourcePath: orphanPath, + targetPath: "plugin state:imessage.catchup-cursors", + namespace: "imessage.catchup-cursors", + }, + { + kind: "plugin-state-import", + label: "iMessage reply short-id counter", + sourcePath: path.join(imsgDir, "reply-cache.jsonl"), + targetPath: "plugin state:imessage.reply-cache-counter", + namespace: "imessage.reply-cache-counter", + }, + { + kind: "plugin-state-import", + label: "iMessage reply short-id cache", + sourcePath: path.join(imsgDir, "reply-cache.jsonl"), + targetPath: "plugin state:imessage.reply-cache", + namespace: "imessage.reply-cache", + }, + { + kind: "plugin-state-import", + label: "iMessage sent-echo dedupe cache", + sourcePath: path.join(imsgDir, "sent-echoes.jsonl"), + targetPath: "plugin state:imessage.sent-echoes", + namespace: "imessage.sent-echoes", + }, + ]); + await expect( + stateMigrations[0]?.detectLegacyState({ + config: { channels: { imessage: { enabled: true } } } as never, + env: {}, + stateDir, + oauthDir: path.join(stateDir, "credentials"), + context: { openPluginStateKeyedStore: () => ({}) } as never, + }), + ).resolves.toEqual({ preview: plans.map(buildLegacyMigrationPreview) }); + const catchupPlan = plans.find((plan) => plan.label === "iMessage catchup cursor"); expect(catchupPlan?.kind).toBe("plugin-state-import"); if (!catchupPlan || catchupPlan.kind !== "plugin-state-import") { diff --git a/extensions/irc/openclaw.plugin.json b/extensions/irc/openclaw.plugin.json index b340dcedaaa3..316889bddaf5 100644 --- a/extensions/irc/openclaw.plugin.json +++ b/extensions/irc/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "irc", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "activation": { "onStartup": false diff --git a/extensions/llm-task/openclaw.plugin.json b/extensions/llm-task/openclaw.plugin.json index 60da1a70593d..8372171fe8f3 100644 --- a/extensions/llm-task/openclaw.plugin.json +++ b/extensions/llm-task/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "llm-task", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "activation": { "onStartup": true diff --git a/extensions/matrix/openclaw.plugin.json b/extensions/matrix/openclaw.plugin.json index 7ec9d0c6bb43..c1a355f50abe 100644 --- a/extensions/matrix/openclaw.plugin.json +++ b/extensions/matrix/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "matrix", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true, + "configRepair": true, "stateMigrations": true }, "name": "Matrix", diff --git a/extensions/mattermost/openclaw.plugin.json b/extensions/mattermost/openclaw.plugin.json index c43d20624db4..ae9a68a8bfb1 100644 --- a/extensions/mattermost/openclaw.plugin.json +++ b/extensions/mattermost/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "mattermost", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "icon": "https://cdn.simpleicons.org/mattermost", "activation": { diff --git a/extensions/memory-wiki/openclaw.plugin.json b/extensions/memory-wiki/openclaw.plugin.json index 0678cbaa2192..3e9979654ff5 100644 --- a/extensions/memory-wiki/openclaw.plugin.json +++ b/extensions/memory-wiki/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "memory-wiki", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true, + "configRepair": true, "stateMigrations": true }, "activation": { diff --git a/extensions/msteams/openclaw.plugin.json b/extensions/msteams/openclaw.plugin.json index 6d92382e7657..bd99f7950b64 100644 --- a/extensions/msteams/openclaw.plugin.json +++ b/extensions/msteams/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "msteams", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true, + "configRepair": true, "stateMigrations": true }, "name": "Microsoft Teams", diff --git a/extensions/nextcloud-talk/openclaw.plugin.json b/extensions/nextcloud-talk/openclaw.plugin.json index 7d9e8ff56672..feb01db2c582 100644 --- a/extensions/nextcloud-talk/openclaw.plugin.json +++ b/extensions/nextcloud-talk/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "nextcloud-talk", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "name": "Nextcloud Talk", "description": "OpenClaw Nextcloud Talk channel plugin for conversations.", diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 6cea2025e968..ee74ea9f2d20 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "ollama", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "icon": "https://cdn.simpleicons.org/ollama", "activation": { diff --git a/extensions/qqbot/openclaw.plugin.json b/extensions/qqbot/openclaw.plugin.json index da1eaf3992ac..26053a73cb1f 100644 --- a/extensions/qqbot/openclaw.plugin.json +++ b/extensions/qqbot/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "qqbot", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true, + "configRepair": true, "stateMigrations": true }, "name": "QQ Bot", diff --git a/extensions/reef/openclaw.plugin.json b/extensions/reef/openclaw.plugin.json index 5396ed29c46b..fcee8cef83bb 100644 --- a/extensions/reef/openclaw.plugin.json +++ b/extensions/reef/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "reef", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true, + "configRepair": true, "stateMigrations": true }, "name": "Reef", diff --git a/extensions/signal/openclaw.plugin.json b/extensions/signal/openclaw.plugin.json index b3732af40c0c..2292fbf264ed 100644 --- a/extensions/signal/openclaw.plugin.json +++ b/extensions/signal/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "signal", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "icon": "https://cdn.simpleicons.org/signal", "activation": { diff --git a/extensions/slack/openclaw.plugin.json b/extensions/slack/openclaw.plugin.json index 0fd596163cb1..125944fb6aa4 100644 --- a/extensions/slack/openclaw.plugin.json +++ b/extensions/slack/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "slack", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "name": "Slack", "description": "OpenClaw Slack channel plugin for channels, DMs, commands, and app events.", diff --git a/extensions/telegram/doctor-contract-api.ts b/extensions/telegram/doctor-contract-api.ts index fc0e8579010b..0aba04089ccd 100644 --- a/extensions/telegram/doctor-contract-api.ts +++ b/extensions/telegram/doctor-contract-api.ts @@ -1,2 +1,13 @@ // Telegram API module exposes the plugin public contract. +import { definePluginDoctorMigrationFromPlans } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js"; + export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; + +export const stateMigrations = [ + definePluginDoctorMigrationFromPlans({ + id: "telegram-legacy-state", + label: "Telegram legacy state", + resolvePlans: detectTelegramLegacyStateMigrations, + }), +]; diff --git a/extensions/telegram/legacy-state-migrations-api.ts b/extensions/telegram/legacy-state-migrations-api.ts deleted file mode 100644 index ef81674b8fb5..000000000000 --- a/extensions/telegram/legacy-state-migrations-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Telegram API module exposes the plugin public contract. -export { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js"; diff --git a/extensions/telegram/openclaw.plugin.json b/extensions/telegram/openclaw.plugin.json index dbf2fbfd7817..fbbaa3db64a0 100644 --- a/extensions/telegram/openclaw.plugin.json +++ b/extensions/telegram/openclaw.plugin.json @@ -1,8 +1,8 @@ { "id": "telegram", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true, + "stateMigrations": true }, "icon": "https://cdn.simpleicons.org/telegram", "activation": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 2808c79f4be9..41d062037fed 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -21,8 +21,7 @@ ], "setupEntry": "./setup-entry.ts", "setupFeatures": { - "configPromotion": true, - "legacyStateMigrations": true + "configPromotion": true }, "channel": { "id": "telegram", diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts index f5f86ecc8e2e..29c5848a4ba0 100644 --- a/extensions/telegram/setup-entry.ts +++ b/extensions/telegram/setup-entry.ts @@ -3,17 +3,10 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr export default defineBundledChannelSetupEntry({ importMetaUrl: import.meta.url, - features: { - legacyStateMigrations: true, - }, plugin: { specifier: "./setup-plugin-api.js", exportName: "telegramSetupPlugin", }, - legacyStateMigrations: { - specifier: "./legacy-state-migrations-api.js", - exportName: "detectTelegramLegacyStateMigrations", - }, secrets: { specifier: "./secret-contract-api.js", exportName: "channelSecrets", diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index d9535747847c..150909bc02df 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -5,14 +5,10 @@ import type { TelegramProbe } from "./probe.js"; import { telegramSetupContract } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; import { createTelegramPluginBase } from "./shared.js"; -import { detectTelegramLegacyStateMigrations } from "./state-migrations.js"; export const telegramSetupPlugin: ChannelPlugin = { ...createTelegramPluginBase({ setupWizard: telegramSetupWizard, setupContract: telegramSetupContract, }), - lifecycle: { - detectLegacyStateMigrations: (params) => detectTelegramLegacyStateMigrations(params), - }, }; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index d222facee1b8..a19ef08f8977 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -94,7 +94,6 @@ import { telegramConfigAdapter, } from "./shared.js"; import { withTelegramStartupProbeSlot } from "./startup-probe-limiter.js"; -import { detectTelegramLegacyStateMigrations } from "./state-migrations.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; import { @@ -908,7 +907,6 @@ export const telegramPlugin = createChatChannelPlugin({ await resolveTelegramTargets({ cfg, accountId, inputs, kind }), }, lifecycle: { - detectLegacyStateMigrations: (params) => detectTelegramLegacyStateMigrations(params), onAccountConfigChanged: async ({ prevCfg, nextCfg, accountId }) => { const previousToken = resolveTelegramAccount({ cfg: prevCfg, accountId }).token.trim(); const nextToken = resolveTelegramAccount({ cfg: nextCfg, accountId }).token.trim(); diff --git a/extensions/telegram/src/state-migrations.test.ts b/extensions/telegram/src/state-migrations.test.ts index 11a30d52a277..5d5b1cb9a08e 100644 --- a/extensions/telegram/src/state-migrations.test.ts +++ b/extensions/telegram/src/state-migrations.test.ts @@ -6,8 +6,10 @@ import path from "node:path"; import type { Message } from "grammy/types"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime"; +import { buildLegacyMigrationPreview } from "openclaw/plugin-sdk/runtime-doctor-migrations"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { stateMigrations } from "../doctor-contract-api.js"; import { resolveTelegramBotInfoCachePath } from "./bot-info-cache.js"; import { resolveTelegramMessageCachePath } from "./message-cache-persistence.js"; import { detectTelegramLegacyStateMigrations } from "./state-migrations.js"; @@ -330,8 +332,11 @@ describe("telegram state migrations", () => { const storePath = resolveStorePath(undefined, { env, agentId: "main" }); const now = Date.now(); const updateOffsetPath = path.join(dir, "telegram", "update-offset-ops.json"); + const botInfoPath = resolveTelegramBotInfoCachePath("ops", env); const stickerCachePath = path.join(dir, "telegram", "sticker-cache.json"); + const messageCachePath = resolveTelegramMessageCachePath(storePath); const sentMessagePath = `${storePath}.telegram-sent-messages.json`; + const topicNamePath = resolveTopicNameCachePath(storePath); const threadBindingsPath = path.join(dir, "telegram", "thread-bindings-ops.json"); try { await mkdir(path.dirname(updateOffsetPath), { recursive: true }); @@ -345,6 +350,15 @@ describe("telegram state migrations", () => { tokenFingerprint: "token:fingerprint", }), ); + await writeFile( + botInfoPath, + JSON.stringify({ + version: 1, + tokenFingerprint: "token:fingerprint", + fetchedAt: "2026-05-24T11:00:00.000Z", + botInfo: { id: 123456, is_bot: true, first_name: "OpenClaw" }, + }), + ); await writeFile( stickerCachePath, JSON.stringify({ @@ -359,7 +373,12 @@ describe("telegram state migrations", () => { }, }), ); + await writeFile(messageCachePath, JSON.stringify([persistedCacheEntry(42, "hello")])); await writeFile(sentMessagePath, JSON.stringify({ 7: { 42: now } })); + await writeFile( + topicNamePath, + JSON.stringify({ "7:42": { name: "Deployments", updatedAt: now } }), + ); await writeFile( threadBindingsPath, JSON.stringify({ @@ -389,6 +408,75 @@ describe("telegram state migrations", () => { } as OpenClawConfig; const plans = await detectTelegramLegacyStateMigrations({ cfg, env }); + expect( + plans.map((plan) => ({ + kind: plan.kind, + label: plan.label, + sourcePath: plan.sourcePath, + targetPath: plan.targetPath, + namespace: plan.kind === "plugin-state-import" ? plan.namespace : null, + })), + ).toEqual([ + { + kind: "plugin-state-import", + label: "Telegram update offset", + sourcePath: updateOffsetPath, + targetPath: "plugin state:telegram.update-offsets", + namespace: "telegram.update-offsets", + }, + { + kind: "plugin-state-import", + label: "Telegram startup bot info cache", + sourcePath: botInfoPath, + targetPath: "plugin state:telegram.bot-info-cache", + namespace: "telegram.bot-info-cache", + }, + { + kind: "plugin-state-import", + label: "Telegram sticker cache", + sourcePath: stickerCachePath, + targetPath: "plugin state:telegram.sticker-cache", + namespace: "telegram.sticker-cache", + }, + { + kind: "plugin-state-import", + label: "Telegram prompt-context message cache", + sourcePath: messageCachePath, + targetPath: "plugin state:telegram.message-cache", + namespace: "telegram.message-cache", + }, + { + kind: "plugin-state-import", + label: "Telegram sent-message cache", + sourcePath: sentMessagePath, + targetPath: "plugin state:telegram.sent-messages", + namespace: "telegram.sent-messages", + }, + { + kind: "plugin-state-import", + label: "Telegram forum topic-name cache", + sourcePath: topicNamePath, + targetPath: `plugin state:${resolveTopicNameCacheNamespace(resolveTopicNameCacheScope(storePath))}`, + namespace: resolveTopicNameCacheNamespace(resolveTopicNameCacheScope(storePath)), + }, + { + kind: "plugin-state-import", + label: "Telegram thread bindings", + sourcePath: threadBindingsPath, + targetPath: "plugin state:telegram.thread-bindings", + namespace: "telegram.thread-bindings", + }, + ]); + await expect( + stateMigrations[0]?.detectLegacyState({ + config: cfg, + env, + stateDir: dir, + oauthDir: path.join(dir, "credentials"), + context: { openPluginStateKeyedStore: vi.fn() } as never, + }), + ).resolves.toEqual({ preview: plans.map(buildLegacyMigrationPreview) }); + const byLabel = new Map(plans.map((plan) => [plan.label, plan])); expect(byLabel.get("Telegram update offset")).toMatchObject({ kind: "plugin-state-import", diff --git a/extensions/tlon/openclaw.plugin.json b/extensions/tlon/openclaw.plugin.json index ceeefcd0c9d9..6236985d1fd7 100644 --- a/extensions/tlon/openclaw.plugin.json +++ b/extensions/tlon/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "tlon", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "name": "Tlon/Urbit", "description": "OpenClaw Tlon/Urbit channel plugin for chat workflows.", diff --git a/extensions/whatsapp/doctor-contract-api.ts b/extensions/whatsapp/doctor-contract-api.ts index 66e2c93b7607..28d4e0d5d6ca 100644 --- a/extensions/whatsapp/doctor-contract-api.ts +++ b/extensions/whatsapp/doctor-contract-api.ts @@ -1,2 +1,13 @@ // Whatsapp API module exposes the plugin public contract. +import { definePluginDoctorMigrationFromPlans } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js"; + export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js"; + +export const stateMigrations = [ + definePluginDoctorMigrationFromPlans({ + id: "whatsapp-legacy-state", + label: "WhatsApp legacy state", + resolvePlans: detectWhatsAppLegacyStateMigrations, + }), +]; diff --git a/extensions/whatsapp/legacy-state-migrations-api.ts b/extensions/whatsapp/legacy-state-migrations-api.ts deleted file mode 100644 index e8a57390d88e..000000000000 --- a/extensions/whatsapp/legacy-state-migrations-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Whatsapp API module exposes the plugin public contract. -export { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js"; diff --git a/extensions/whatsapp/openclaw.plugin.json b/extensions/whatsapp/openclaw.plugin.json index 9f74202761c4..84583f95eff8 100644 --- a/extensions/whatsapp/openclaw.plugin.json +++ b/extensions/whatsapp/openclaw.plugin.json @@ -1,8 +1,8 @@ { "id": "whatsapp", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true, + "stateMigrations": true }, "name": "WhatsApp", "description": "OpenClaw WhatsApp channel plugin for WhatsApp Web chats.", diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 74685baaffc6..3a443ed765cf 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -31,7 +31,6 @@ "setupEntry": "./setup-entry.ts", "setupFeatures": { "configPromotion": true, - "legacyStateMigrations": true, "legacySessionSurfaces": true }, "channel": { diff --git a/extensions/whatsapp/setup-entry.test.ts b/extensions/whatsapp/setup-entry.test.ts index d0fc62f9fb97..2fe041fa3d28 100644 --- a/extensions/whatsapp/setup-entry.test.ts +++ b/extensions/whatsapp/setup-entry.test.ts @@ -2,11 +2,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { buildLegacyMigrationPreview } from "openclaw/plugin-sdk/runtime-doctor-migrations"; import { describe, expect, it, vi } from "vitest"; +import { stateMigrations } from "./doctor-contract-api.js"; import * as legacySessionSurfaceApi from "./legacy-session-surface-api.js"; -import * as legacyStateMigrationsApi from "./legacy-state-migrations-api.js"; import setupEntry from "./setup-entry.js"; import * as setupPluginApi from "./setup-plugin-api.js"; +import { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js"; vi.mock("baileys", () => { throw new Error("setup plugin load must not load Baileys"); @@ -21,9 +23,6 @@ const setupEntryLoadOptions = { if (/[\\/]setup-plugin-api\.[jt]s$/u.test(specifier)) { return setupPluginApi; } - if (/[\\/]legacy-state-migrations-api\.[jt]s$/u.test(specifier)) { - return legacyStateMigrationsApi; - } if (/[\\/]legacy-session-surface-api\.[jt]s$/u.test(specifier)) { return legacySessionSurfaceApi; } @@ -34,10 +33,7 @@ const setupEntryLoadOptions = { describe("whatsapp setup entry", () => { it("loads setup entry metadata without importing runtime dependencies", () => { expect(setupEntry.kind).toBe("bundled-channel-setup-entry"); - expect(setupEntry.features).toEqual({ - legacySessionSurfaces: true, - legacyStateMigrations: true, - }); + expect(setupEntry.features).toEqual({ legacySessionSurfaces: true }); }); it("loads the setup plugin without installing runtime dependencies", () => { @@ -45,20 +41,7 @@ describe("whatsapp setup entry", () => { expect(whatsappSetupPlugin.id).toBe("whatsapp"); }); - it("loads legacy setup helpers without importing runtime dependencies", () => { - const detectLegacyStateMigrations = - setupEntry.loadLegacyStateMigrationDetector?.(setupEntryLoadOptions); - if (!detectLegacyStateMigrations) { - throw new Error("expected WhatsApp legacy state migration detector"); - } - expect( - detectLegacyStateMigrations({ - cfg: {}, - env: {}, - oauthDir: "/tmp/openclaw-whatsapp-empty", - stateDir: "/tmp/openclaw-state", - }), - ).toStrictEqual([]); + it("loads the legacy session helper without importing runtime dependencies", () => { const legacySessionSurface = setupEntry.loadLegacySessionSurface?.(setupEntryLoadOptions); if (!legacySessionSurface) { throw new Error("expected WhatsApp legacy session surface"); @@ -96,22 +79,41 @@ describe("whatsapp setup entry", () => { fs.writeFileSync(path.join(oauthDir, "nested", "session-keep.json"), "{}", "utf-8"); fs.symlinkSync(path.join(oauthDir, "notes.txt"), path.join(oauthDir, "session-linked.json")); - const detectLegacyStateMigrations = - setupEntry.loadLegacyStateMigrationDetector?.(setupEntryLoadOptions); - if (!detectLegacyStateMigrations) { - throw new Error("expected WhatsApp legacy state migration detector"); - } - const migrations = - (await detectLegacyStateMigrations({ - cfg: {}, - env: {}, - oauthDir, - stateDir: oauthDir, - })) ?? []; + const migrations = detectWhatsAppLegacyStateMigrations({ oauthDir }); expect(migrations.map((migration) => path.basename(migration.sourcePath)).toSorted()).toEqual( authFiles.toSorted(), ); + expect( + migrations + .map((migration) => ({ + kind: migration.kind, + label: migration.label, + sourcePath: migration.sourcePath, + targetPath: migration.targetPath, + namespace: null, + })) + .toSorted((left, right) => left.sourcePath.localeCompare(right.sourcePath)), + ).toEqual( + authFiles + .map((fileName) => ({ + kind: "move", + label: `WhatsApp auth ${fileName}`, + sourcePath: path.join(oauthDir, fileName), + targetPath: path.join(oauthDir, "whatsapp", "default", fileName), + namespace: null, + })) + .toSorted((left, right) => left.sourcePath.localeCompare(right.sourcePath)), + ); + await expect( + stateMigrations[0]?.detectLegacyState({ + config: {}, + env: {}, + oauthDir, + stateDir: oauthDir, + context: { openPluginStateKeyedStore: vi.fn() } as never, + }), + ).resolves.toEqual({ preview: migrations.map(buildLegacyMigrationPreview) }); for (const migration of migrations) { expect(migration.targetPath).toBe( path.join(oauthDir, "whatsapp", "default", path.basename(migration.sourcePath)), diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts index 3e70157e46bc..7958a1e10842 100644 --- a/extensions/whatsapp/setup-entry.ts +++ b/extensions/whatsapp/setup-entry.ts @@ -4,17 +4,12 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr export default defineBundledChannelSetupEntry({ importMetaUrl: import.meta.url, features: { - legacyStateMigrations: true, legacySessionSurfaces: true, }, plugin: { specifier: "./setup-plugin-api.js", exportName: "whatsappSetupPlugin", }, - legacyStateMigrations: { - specifier: "./legacy-state-migrations-api.js", - exportName: "detectWhatsAppLegacyStateMigrations", - }, legacySessionSurface: { specifier: "./legacy-session-surface-api.js", exportName: "whatsappLegacySessionSurface", diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index a9a4a0860926..53fd5cc5d491 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -8,7 +8,6 @@ import { } from "./group-policy.js"; import { whatsappSetupContract } from "./setup-core.js"; import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js"; -import { detectWhatsAppLegacyStateMigrations } from "./state-migrations.js"; export const whatsappSetupPlugin: ChannelPlugin = { ...createWhatsAppPluginBase({ @@ -21,8 +20,4 @@ export const whatsappSetupPlugin: ChannelPlugin = { isConfigured: (account) => Boolean(account.authDir), isLinked: async (account) => await readWhatsAppAccountLinkState(account.authDir), }), - lifecycle: { - detectLegacyStateMigrations: ({ oauthDir }) => - detectWhatsAppLegacyStateMigrations({ oauthDir }), - }, }; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 91daffda4bf5..7564dbe84f0a 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -41,7 +41,6 @@ import { sendTypingWhatsApp } from "./send.js"; import { resolveWhatsAppOutboundSessionRoute } from "./session-route.js"; import { whatsappSetupContract } from "./setup-core.js"; import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js"; -import { detectWhatsAppLegacyStateMigrations } from "./state-migrations.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; const loadWhatsAppDirectoryConfig = createLazyRuntimeModule(() => import("./directory-config.js")); @@ -212,10 +211,6 @@ export const whatsappPlugin: ChannelPlugin = ).loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId); }, }, - lifecycle: { - detectLegacyStateMigrations: ({ oauthDir }) => - detectWhatsAppLegacyStateMigrations({ oauthDir }), - }, heartbeat: { checkReady: async ({ cfg, accountId, deps }) => await checkWhatsAppHeartbeatReady({ cfg, accountId: accountId ?? undefined, deps }), diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index a1a8cd11adff..00195b1790df 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "xai", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true + "configRepair": true }, "activation": { "onStartup": false diff --git a/extensions/zalouser/openclaw.plugin.json b/extensions/zalouser/openclaw.plugin.json index 6bc5010b981a..73b8acfebe2a 100644 --- a/extensions/zalouser/openclaw.plugin.json +++ b/extensions/zalouser/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "zalouser", "doctorContract": { - "legacyConfigRules": true, - "normalizeCompatibilityConfig": true, + "configRepair": true, "stateMigrations": true }, "name": "Zalo Personal", diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 793c25c7faa9..73f4a9710dd6 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -100,6 +100,8 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ core: 2, routing: 1, health: 0, + // +1: shipped channel setup state-migration declaration during its migration window. + "channel-entry-contract": 1, "channel-streaming": 54, "approval-gateway-runtime": 1, "approval-handler-runtime": 1, @@ -290,7 +292,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: flushLogger projected through the deprecated text-runtime barrel. // +1: shared ingress error factory projected through channel-message. // +1: shared ingress retention defaults projected through channel-message. - 1703, + // +1: shipped channel setup state-migration declaration during its migration window. + 1704, env, ), publicWildcardReexports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/channels/plugins/bundled-setup-policy.ts b/src/channels/plugins/bundled-setup-policy.ts new file mode 100644 index 000000000000..a098ccfb0db7 --- /dev/null +++ b/src/channels/plugins/bundled-setup-policy.ts @@ -0,0 +1,45 @@ +import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { BundledChannelPluginMetadata } from "../../plugins/bundled-channel-runtime.js"; +import { normalizePluginsConfig } from "../../plugins/config-state.js"; +import { passesManifestOwnerBasePolicy } from "../../plugins/manifest-owner-policy.js"; + +export function shouldIncludeBundledChannelSetupFeatureForConfig(params: { + metadata: BundledChannelPluginMetadata; + config?: OpenClawConfig; +}): boolean { + if (!params.config) { + return true; + } + const pluginId = params.metadata.manifest.id; + if ( + !passesManifestOwnerBasePolicy({ + plugin: { id: pluginId }, + normalizedConfig: normalizePluginsConfig(params.config.plugins), + allowRestrictiveAllowlistBypass: true, + }) + ) { + return false; + } + + let hasExplicitChannelDisable = false; + for (const channelId of params.metadata.manifest.channels ?? [pluginId]) { + const normalizedChannelId = normalizeOptionalLowercaseString(channelId); + if (!normalizedChannelId) { + continue; + } + const channelConfig = (params.config.channels as Record | undefined)?.[ + normalizedChannelId + ]; + if (!channelConfig || typeof channelConfig !== "object" || Array.isArray(channelConfig)) { + continue; + } + if ((channelConfig as { enabled?: unknown }).enabled === false) { + hasExplicitChannelDisable = true; + continue; + } + return true; + } + + return !hasExplicitChannelDisable; +} diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index e73a867c8ad3..2597a6a91856 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -791,26 +791,35 @@ describe("bundled channel entry shape guards", () => { ); expect( - bundled.listBundledChannelLegacyStateMigrationDetectors({ + bundled.listBundledChannelLegacyStateMigrationDetectorEntries({ config: { channels: { alpha: { enabled: false } } }, }), ).toStrictEqual([]); expect(testGlobal["__bundledSetupOnlySetupLoaded"]).toBeUndefined(); - const detectors = bundled.listBundledChannelLegacyStateMigrationDetectors(); + const detectorEntries = bundled.listBundledChannelLegacyStateMigrationDetectorEntries(); expect( - detectors.map((detector) => - detector({ cfg: {}, env: {}, stateDir: "/state", oauthDir: "/oauth" } as never), - ), + detectorEntries.map(({ pluginId, detector }) => ({ + pluginId, + plans: detector({ + cfg: {}, + env: {}, + stateDir: "/state", + oauthDir: "/oauth", + } as never), + })), ).toEqual([ - [ - { - kind: "copy", - label: "Alpha state", - sourcePath: "/oauth/legacy.json", - targetPath: "/oauth/alpha/legacy.json", - }, - ], + { + pluginId: "alpha", + plans: [ + { + kind: "copy", + label: "Alpha state", + sourcePath: "/oauth/legacy.json", + targetPath: "/oauth/alpha/legacy.json", + }, + ], + }, ]); expect(testGlobal["__bundledSetupOnlySetupLoaded"]).toBe(1); expect(testGlobal["__bundledSetupOnlyMainLoaded"]).toBeUndefined(); diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 27ddbe6ff522..b5c6fc40a66e 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -5,7 +5,6 @@ */ import fs from "node:fs"; import path from "node:path"; -import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { extractErrorCode, formatErrorMessage } from "../../infra/errors.js"; import { pruneMapToMaxSize } from "../../infra/map-size.js"; @@ -21,14 +20,13 @@ import { resolveBundledChannelGeneratedPath, type BundledChannelPluginMetadata, } from "../../plugins/bundled-channel-runtime.js"; -import { normalizePluginsConfig } from "../../plugins/config-state.js"; -import { passesManifestOwnerBasePolicy } from "../../plugins/manifest-owner-policy.js"; import { unwrapDefaultModuleExport } from "../../plugins/module-export.js"; import { getCachedPluginModuleLoader, type PluginModuleLoaderCache, } from "../../plugins/plugin-module-loader-cache.js"; import { resolveBundledChannelRootScope, type BundledChannelRootScope } from "./bundled-root.js"; +import { shouldIncludeBundledChannelSetupFeatureForConfig } from "./bundled-setup-policy.js"; import { normalizeChannelMeta } from "./meta-normalization.js"; import { loadChannelPluginModule } from "./module-loader.js"; import type { ChannelPlugin } from "./types.plugin.js"; @@ -416,53 +414,18 @@ function listBundledChannelPluginIdsForRoot( .toSorted((left, right) => left.localeCompare(right)); } -function shouldIncludeBundledChannelSetupFeatureForConfig(params: { - metadata: BundledChannelPluginMetadata; - config?: OpenClawConfig; -}): boolean { - if (!params.config) { - return true; - } - const pluginId = params.metadata.manifest.id; - if ( - !passesManifestOwnerBasePolicy({ - plugin: { id: pluginId }, - normalizedConfig: normalizePluginsConfig(params.config.plugins), - allowRestrictiveAllowlistBypass: true, - }) - ) { - return false; - } - - let hasExplicitChannelDisable = false; - for (const channelId of params.metadata.manifest.channels ?? [pluginId]) { - const normalizedChannelId = normalizeOptionalLowercaseString(channelId); - if (!normalizedChannelId) { - continue; - } - const channelConfig = (params.config.channels as Record | undefined)?.[ - normalizedChannelId - ]; - if (!channelConfig || typeof channelConfig !== "object" || Array.isArray(channelConfig)) { - continue; - } - if ((channelConfig as { enabled?: unknown }).enabled === false) { - hasExplicitChannelDisable = true; - continue; - } - return true; - } - - return !hasExplicitChannelDisable; -} - function listBundledChannelPluginIdsForSetupFeature( rootScope: BundledChannelRootScope, feature: keyof NonNullable, - options: { config?: OpenClawConfig } = {}, + options: { config?: OpenClawConfig; pluginIds?: readonly string[] } = {}, ): readonly ChannelId[] { - const eligible = listBundledChannelMetadata(rootScope).filter((metadata) => - shouldIncludeBundledChannelSetupFeatureForConfig({ metadata, config: options.config }), + const scopedPluginIds = options.pluginIds ? new Set(options.pluginIds) : null; + const eligible = listBundledChannelMetadata(rootScope).filter( + (metadata) => + (!scopedPluginIds || + scopedPluginIds.has(metadata.manifest.id) || + metadata.manifest.channels?.some((channelId) => scopedPluginIds.has(channelId))) && + shouldIncludeBundledChannelSetupFeatureForConfig({ metadata, config: options.config }), ); const hinted = eligible.filter( (metadata) => metadata.packageManifest?.setupFeatures?.[feature] === true, @@ -667,25 +630,30 @@ export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { }); } +type BundledChannelLegacyArtifact = { + pluginId: ChannelId; + artifact: TArtifact; +}; + function listBundledChannelLegacyArtifacts( feature: keyof NonNullable, - options: { config?: OpenClawConfig }, + options: { config?: OpenClawConfig; pluginIds?: readonly string[] }, loadFromEntry: (entry: BundledChannelSetupEntryRuntimeContract) => TArtifact | undefined, loadFromPlugin: (plugin: ChannelPlugin) => TArtifact | undefined, -): readonly TArtifact[] { +): readonly BundledChannelLegacyArtifact[] { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForSetupFeature(rootScope, feature, options).flatMap((id) => { const entry = getBundledChannelArtifactForRoot("setupEntry", id, rootScope, loadContext); const artifact = entry ? loadFromEntry(entry) : undefined; if (artifact) { - return [artifact]; + return [{ pluginId: id, artifact }]; } if (entry?.features?.[feature] !== true) { return []; } const plugin = getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext); const fallback = plugin ? loadFromPlugin(plugin) : undefined; - return fallback ? [fallback] : []; + return fallback ? [{ pluginId: id, artifact: fallback }] : []; }); } @@ -697,18 +665,22 @@ export function listBundledChannelLegacySessionSurfaces( options, (entry) => entry.loadLegacySessionSurface?.(), (plugin) => plugin.messaging, - ); + ).map((entry) => entry.artifact); } -export function listBundledChannelLegacyStateMigrationDetectors( - options: { config?: OpenClawConfig } = {}, -): readonly BundledChannelLegacyStateMigrationDetector[] { +/** Deprecated setup-entry migrations adapted into the plugin doctor pipeline. */ +export function listBundledChannelLegacyStateMigrationDetectorEntries( + options: { config?: OpenClawConfig; pluginIds?: readonly string[] } = {}, +): ReadonlyArray<{ + pluginId: ChannelId; + detector: BundledChannelLegacyStateMigrationDetector; +}> { return listBundledChannelLegacyArtifacts( "legacyStateMigrations", options, (entry) => entry.loadLegacyStateMigrationDetector?.(), (plugin) => plugin.lifecycle?.detectLegacyStateMigrations, - ); + ).map(({ pluginId, artifact }) => ({ pluginId, detector: artifact })); } export function getBundledChannelAccountInspector( diff --git a/src/channels/plugins/legacy-state-migration-preview.ts b/src/channels/plugins/legacy-state-migration-preview.ts new file mode 100644 index 000000000000..d3dc093c7285 --- /dev/null +++ b/src/channels/plugins/legacy-state-migration-preview.ts @@ -0,0 +1,8 @@ +import type { ChannelLegacyStateMigrationPlan } from "./legacy-state-migration.types.js"; + +export function buildLegacyMigrationPreview(plan: ChannelLegacyStateMigrationPlan): string { + if (plan.kind === "plugin-state-import") { + return plan.preview ?? `- ${plan.label}: ${plan.sourcePath}`; + } + return `- ${plan.label}: ${plan.sourcePath} → ${plan.targetPath}`; +} diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 65b8867e5852..0a82d26ac862 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -532,6 +532,10 @@ export type ChannelLifecycleAdapter = { trigger?: string; logPrefix?: string; }) => Promise | void; + /** + * @deprecated Export stateMigrations from the plugin doctor contract instead. + * Removal plan: remove the lifecycle adapter after the 2027.1 external-plugin migration window. + */ detectLegacyStateMigrations?: (params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index b44f18a7da42..77423a333bd5 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -46,6 +46,17 @@ let tempRoots: string[] = []; const mockedChannelMigrationPlans = vi.hoisted(() => ({ plans: [] as Array>, })); +const mockedLegacyMigrationDetectors = vi.hoisted(() => ({ + entries: [] as Array<{ + pluginId: string; + detector: (params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + oauthDir: string; + }) => Array>; + }>, +})); vi.mock("../channels/plugins/bundled.js", async () => { const actual = await vi.importActual( @@ -83,6 +94,17 @@ vi.mock("../channels/plugins/bundled.js", async () => { }); } + mockedLegacyMigrationDetectors.entries = [ + { + pluginId: "whatsapp", + detector: ({ oauthDir }: { oauthDir: string }) => + detectWhatsAppLegacyStateMigrations({ oauthDir }), + }, + { + pluginId: "test-channel", + detector: () => mockedChannelMigrationPlans.plans, + }, + ]; return { ...actual, listBundledChannelLegacySessionSurfaces: vi.fn(() => [ @@ -94,10 +116,9 @@ vi.mock("../channels/plugins/bundled.js", async () => { : null, }, ]), - listBundledChannelLegacyStateMigrationDetectors: vi.fn(() => [ - ({ oauthDir }: { oauthDir: string }) => detectWhatsAppLegacyStateMigrations({ oauthDir }), - () => mockedChannelMigrationPlans.plans, - ]), + listBundledChannelLegacyStateMigrationDetectorEntries: vi.fn( + () => mockedLegacyMigrationDetectors.entries, + ), }; }); @@ -132,11 +153,27 @@ vi.mock("../infra/json-files.js", async () => { }; }); -vi.mock("../plugins/doctor-contract-registry.js", () => ({ - collectRelevantDoctorPluginIds: vi.fn(() => []), - listPluginDoctorSessionStoreAgentIds: vi.fn(() => []), - listPluginDoctorStateMigrationEntries: vi.fn(() => []), -})); +vi.mock("../plugins/doctor-contract-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + const { definePluginDoctorMigrationFromPlans } = await vi.importActual< + typeof import("../plugin-sdk/runtime-doctor-migrations.js") + >("../plugin-sdk/runtime-doctor-migrations.js"); + return { + ...actual, + collectRelevantDoctorPluginIds: vi.fn(() => []), + listPluginDoctorSessionStoreAgentIds: vi.fn(() => []), + listPluginDoctorStateMigrationEntries: vi.fn(() => + mockedLegacyMigrationDetectors.entries.map(({ pluginId, detector }) => ({ + pluginId, + migration: definePluginDoctorMigrationFromPlans({ + id: `${pluginId}-legacy-channel-state`, + label: `${pluginId} legacy channel state`, + resolvePlans: detector as never, + }), + })), + ), + }; +}); async function makeTempRoot() { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-")); diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index e9acbdd7d2a2..7f31e004cdae 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -352,10 +352,6 @@ function createLegacyStateMigrationDetectionResult(params?: { accountIds: {}, hasLegacy: false, }, - channelPlans: { - hasLegacy: false, - plans: [], - }, warnings: [], notices: [], preview: params?.preview ?? [], diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index 742eb23c6250..bb9b5ad3ac77 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -8,10 +8,8 @@ import { listRegistryWorktreesForMigration, rewriteRegistryWorktreePathsForMigration, } from "../agents/worktrees/registry.js"; -import { listBundledChannelLegacyStateMigrationDetectors } from "../channels/plugins/bundled.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelPlugin } from "../channels/plugins/registry.js"; -import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -106,7 +104,6 @@ import { import { migrateLegacyInstalledPluginIndex, migrateLegacyPluginStateSidecar, - runLegacyMigrationPlans, } from "./state-migrations.plugin-state.js"; import { detectLegacyRescuePending, @@ -144,7 +141,6 @@ import { import { PLUGIN_STATE_SQLITE_SIDECAR_SUFFIXES, TASK_STATE_SQLITE_SIDECAR_SUFFIXES, - buildLegacyMigrationPreview, hasPendingSqliteSidecarArchive, listLegacyDeliveryQueueDeliveredMarkers, listLegacyDeliveryQueueFiles, @@ -206,36 +202,6 @@ export function resetAutoMigrateLegacyStateForTest(): void { resetLegacySessionSurfacesForTest(); } -async function collectChannelLegacyStateMigrationPlans(params: { - cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; - stateDir: string; - oauthDir: string; -}): Promise { - const plans: ChannelLegacyStateMigrationPlan[] = []; - // Legacy state detection belongs on a narrow setup-entry surface so doctor - // does not cold-load unrelated runtime channel code. - const detectors = listBundledChannelLegacyStateMigrationDetectors({ config: params.cfg }); - for (const detectLegacyStateMigrationsLocal of detectors) { - const detected = await detectLegacyStateMigrationsLocal({ - cfg: params.cfg, - env: params.env, - stateDir: params.stateDir, - oauthDir: params.oauthDir, - }); - if (detected?.length) { - for (const detectedPlan of detected) { - const plan = - detectedPlan.kind === "plugin-state-import" && !detectedPlan.stateDir - ? { ...detectedPlan, stateDir: params.stateDir } - : detectedPlan; - plans.push(plan); - } - } - } - return plans; -} - async function collectPluginDoctorStateMigrationPlans(params: { cfg: OpenClawConfig; pluginDoctorConfig?: OpenClawConfig; @@ -619,12 +585,6 @@ export async function detectLegacyStateMigrations(params: { ), configuredAccountIds, }); - const channelPlans = await collectChannelLegacyStateMigrationPlans({ - cfg: params.cfg, - env, - stateDir, - oauthDir, - }); const pluginPlanWarnings: string[] = []; const pluginPlans = stateSchemaMigrations.length > 0 @@ -753,9 +713,6 @@ export async function detectLegacyStateMigrations(params: { preview.push(message); } } - if (channelPlans.length > 0) { - preview.push(...channelPlans.map(buildLegacyMigrationPreview)); - } if (pluginPlans.length > 0) { preview.push(...pluginPlans.flatMap((plan) => plan.preview)); } @@ -783,10 +740,6 @@ export async function detectLegacyStateMigrations(params: { targetDir: targetAgentDir, hasLegacy: hasLegacyAgentDir, }, - channelPlans: { - hasLegacy: channelPlans.length > 0, - plans: channelPlans, - }, pluginPlans: { hasLegacy: pluginPlans.length > 0, plans: pluginPlans, @@ -1181,11 +1134,6 @@ function buildLegacyStateMigrationSteps( env: { ...env, OPENCLAW_STATE_DIR: stateDir }, }), ), - finalStep(() => - runLegacyMigrationPlans( - detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"), - ), - ), finalStep( () => isDoctor && detected.stateSchema.hasLegacy @@ -1216,11 +1164,6 @@ function buildLegacyStateMigrationSteps( kind: "acp-session-metadata", }, finalStep(() => migrateLegacyAgentDir(detected, now)), - finalStep(() => - runLegacyMigrationPlans( - detected.channelPlans.plans.filter((plan) => plan.kind !== "plugin-state-import"), - ), - ), ); } @@ -1470,7 +1413,6 @@ export async function autoMigrateLegacyState(params: { !hasCustomAgentDir && !detected.sessions.hasLegacy && !detected.agentDir.hasLegacy && - !detected.channelPlans.hasLegacy && !detected.pluginPlans?.hasLegacy && !detected.pluginStateSidecar.hasLegacy && !detected.pluginInstallIndex.hasLegacy && diff --git a/src/infra/state-migrations.storage.ts b/src/infra/state-migrations.storage.ts index 55f8bc11ded1..12d5f58ef02b 100644 --- a/src/infra/state-migrations.storage.ts +++ b/src/infra/state-migrations.storage.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import path from "node:path"; import type { DatabaseSync, SQLInputValue } from "node:sqlite"; import { expectDefined } from "@openclaw/normalization-core"; -import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import { parseInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js"; import { INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION, @@ -56,13 +55,6 @@ class LegacyTaskStateSidecarConflictError extends Error { } } -export function buildLegacyMigrationPreview(plan: ChannelLegacyStateMigrationPlan): string { - if (plan.kind === "plugin-state-import") { - return plan.preview ?? `- ${plan.label}: ${plan.sourcePath}`; - } - return `- ${plan.label}: ${plan.sourcePath} → ${plan.targetPath}`; -} - export function resolveLegacyPluginStateSidecarPath(stateDir: string): string { return path.join(stateDir, "plugin-state", "state.sqlite"); } diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 1856810246cd..f8f3b5e80a91 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -9,6 +9,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { readMemoryHostEventRecords } from "../memory-host-sdk/events.js"; import { loadNodeHostConfig } from "../node-host/config.js"; import { readChannelPairingStateSnapshot } from "../pairing/pairing-store-sqlite.test-helpers.js"; +import { definePluginDoctorMigrationFromPlans } from "../plugin-sdk/runtime-doctor-migrations.js"; import type { PluginDoctorStateMigration, PluginDoctorStateMigrationContext, @@ -112,18 +113,14 @@ const pluginDoctorStateMigrationEntries = vi.hoisted( }, ); +const legacyChannelStateMigrationEntries = vi.hoisted(() => ({ + entries: [] as Array<{ pluginId: string; migration: PluginDoctorStateMigration }>, +})); + vi.mock("../channels/plugins/bundled.js", async () => { const actual = await vi.importActual( "../channels/plugins/bundled.js", ); - function fileExists(filePath: string): boolean { - try { - return fsSync.statSync(filePath).isFile(); - } catch { - return false; - } - } - return { ...actual, listBundledChannelLegacySessionSurfaces: vi.fn(() => [ @@ -139,33 +136,6 @@ vi.mock("../channels/plugins/bundled.js", async () => { }, }, ]), - listBundledChannelLegacyStateMigrationDetectors: vi.fn(() => [ - ({ oauthDir }: { oauthDir: string }) => { - let entries: fsSync.Dirent[]; - try { - entries = fsSync.readdirSync(oauthDir, { withFileTypes: true }); - } catch { - return []; - } - return entries.flatMap((entry) => { - if (!entry.isFile() || !/^(creds|pre-key-1)\.json$/u.test(entry.name)) { - return []; - } - const sourcePath = path.join(oauthDir, entry.name); - const targetPath = path.join(oauthDir, "mobileauth", "default", entry.name); - return fileExists(targetPath) - ? [] - : [ - { - kind: "move" as const, - label: `MobileAuth auth ${entry.name}`, - sourcePath, - targetPath, - }, - ]; - }); - }, - ]), }; }); @@ -173,7 +143,10 @@ vi.mock("../plugins/doctor-contract-registry.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - listPluginDoctorStateMigrationEntries: vi.fn(() => pluginDoctorStateMigrationEntries.entries), + listPluginDoctorStateMigrationEntries: vi.fn(() => [ + ...pluginDoctorStateMigrationEntries.entries, + ...legacyChannelStateMigrationEntries.entries, + ]), }; }); @@ -213,6 +186,41 @@ function failArchiveRenameOnce(sourcePath: string) { }); } +legacyChannelStateMigrationEntries.entries = [ + { + pluginId: "mobileauth", + migration: definePluginDoctorMigrationFromPlans({ + id: "mobileauth-legacy-state", + label: "MobileAuth legacy state", + resolvePlans: ({ oauthDir }) => { + let entries: fsSync.Dirent[]; + try { + entries = fsSync.readdirSync(oauthDir, { withFileTypes: true }); + } catch { + return []; + } + return entries.flatMap((entry) => { + if (!entry.isFile() || !/^(creds|pre-key-1)\.json$/u.test(entry.name)) { + return []; + } + const sourcePath = path.join(oauthDir, entry.name); + const targetPath = path.join(oauthDir, "mobileauth", "default", entry.name); + return fsSync.existsSync(targetPath) + ? [] + : [ + { + kind: "move" as const, + label: `MobileAuth auth ${entry.name}`, + sourcePath, + targetPath, + }, + ]; + }); + }, + }), + }, +]; + function failNextStateDbCommit(env: NodeJS.ProcessEnv) { const { db } = openOpenClawStateDatabase({ env }); const actualExec = db.exec.bind(db); @@ -1043,10 +1051,10 @@ describe("state migrations", () => { expect(detectionCase.sessions.hasLegacy).toBe(true); expect(detectionCase.sessions.legacyKeys).toEqual(["group:mobile-room", "group:legacy-room"]); expect(detectionCase.agentDir.hasLegacy).toBe(true); - expect(detectionCase.channelPlans.hasLegacy).toBe(true); - expect(detectionCase.channelPlans.plans.map((plan) => plan.targetPath)).toEqual([ - path.join(detectionCase.stateDir, "credentials", "mobileauth", "default", "creds.json"), - ]); + expect(detectionCase.pluginPlans?.hasLegacy).toBe(true); + expect(detectionCase.pluginPlans?.plans.map((plan) => plan.migration.id)).toContain( + "mobileauth-legacy-state", + ); expect(detectionCase.channelPairing.hasLegacy).toBe(true); expect(detectionCase.preview).toEqual([ `- Sessions: ${path.join(detectionCase.stateDir, "sessions")} → ${path.join(detectionCase.stateDir, "agents", "worker-1", "sessions")}`, @@ -1124,14 +1132,14 @@ describe("state migrations", () => { ]); expect(result.changes).toEqual([ "Migrated 2 chatapp/alpha allowFrom entries → shared SQLite state", + `Moved MobileAuth auth creds.json → ${path.join(stateDir, "credentials", "mobileauth", "default", "creds.json")}`, + `Moved MobileAuth auth pre-key-1.json → ${path.join(stateDir, "credentials", "mobileauth", "default", "pre-key-1.json")}`, `Migrated latest direct-chat session → agent:worker-1:desk`, `Merged sessions store → ${path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json")}`, "Canonicalized 3 legacy session key(s)", "Moved trace.jsonl → agents/worker-1/sessions", "Migrated 2 ACP session metadata rows → shared SQLite state", "Moved agent file settings.json → agents/worker-1/agent", - `Moved MobileAuth auth creds.json → ${path.join(stateDir, "credentials", "mobileauth", "default", "creds.json")}`, - `Moved MobileAuth auth pre-key-1.json → ${path.join(stateDir, "credentials", "mobileauth", "default", "pre-key-1.json")}`, ]); const mergedStore = JSON.parse( diff --git a/src/infra/state-migrations.types.ts b/src/infra/state-migrations.types.ts index d083b2524538..361c2549acaa 100644 --- a/src/infra/state-migrations.types.ts +++ b/src/infra/state-migrations.types.ts @@ -1,4 +1,3 @@ -import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import type { SessionScope } from "../config/sessions/types.js"; import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-registry.js"; import type { LegacyAuditLogsDetection } from "./state-migrations.audit-logs.types.js"; @@ -44,10 +43,6 @@ export type LegacyStateDetection = { targetDir: string; hasLegacy: boolean; }; - channelPlans: { - hasLegacy: boolean; - plans: ChannelLegacyStateMigrationPlan[]; - }; pluginPlans?: { hasLegacy: boolean; plans: DetectedPluginDoctorStateMigrationPlan[]; diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index dc9c1bdf6e68..a674b43e45de 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -71,6 +71,10 @@ type DefineBundledChannelSetupEntryOptions = { plugin: BundledEntryModuleRef; secrets?: BundledEntryModuleRef; runtime?: BundledEntryModuleRef; + /** + * @deprecated Export stateMigrations from the plugin doctor contract instead. + * Removal plan: remove the setup-entry adapter after the 2027.1 external-plugin migration window. + */ legacyStateMigrations?: BundledEntryModuleRef; legacySessionSurface?: BundledEntryModuleRef; registerSetupRuntime?: (api: OpenClawPluginApi) => void; @@ -79,6 +83,10 @@ type DefineBundledChannelSetupEntryOptions = { /** Feature flags exposed by bundled setup entries for optional migration/session surfaces. */ export type BundledChannelSetupEntryFeatures = { + /** + * @deprecated Declare doctorContract.stateMigrations in openclaw.plugin.json instead. + * Removal plan: remove the setup-entry adapter after the 2027.1 external-plugin migration window. + */ legacyStateMigrations?: boolean; legacySessionSurfaces?: boolean; }; diff --git a/src/plugin-sdk/channel-entry-contract.types.ts b/src/plugin-sdk/channel-entry-contract.types.ts index e9f1f213326f..845f502f0678 100644 --- a/src/plugin-sdk/channel-entry-contract.types.ts +++ b/src/plugin-sdk/channel-entry-contract.types.ts @@ -11,7 +11,11 @@ export type BundledChannelLegacySessionSurface = { }) => string | null | undefined; }; -/** Detects channel-owned state migrations needed before a bundled channel starts. */ +/** + * Detects channel-owned state migrations needed before a bundled channel starts. + * @deprecated Export stateMigrations from the plugin doctor contract instead. + * Removal plan: remove the setup-entry adapter after the 2027.1 external-plugin migration window. + */ export type BundledChannelLegacyStateMigrationDetector = (params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; diff --git a/src/plugin-sdk/doctor-migration-plan-adapter.ts b/src/plugin-sdk/doctor-migration-plan-adapter.ts new file mode 100644 index 000000000000..28a9db2541ce --- /dev/null +++ b/src/plugin-sdk/doctor-migration-plan-adapter.ts @@ -0,0 +1,64 @@ +import { buildLegacyMigrationPreview } from "../channels/plugins/legacy-state-migration-preview.js"; +import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/legacy-state-migration.types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-module.js"; + +type PluginDoctorPlanResolver = (params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + oauthDir: string; +}) => + | ChannelLegacyStateMigrationPlan[] + | Promise + | null + | undefined; + +/** Adapts legacy channel migration plans to the canonical plugin doctor contract. */ +export function definePluginDoctorMigrationFromPlans(params: { + id: string; + label: string; + doctorOnly?: boolean; + resolvePlans: PluginDoctorPlanResolver; +}): PluginDoctorStateMigration { + const resolvePlans = async (input: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + oauthDir: string; + }): Promise => { + const plans = + (await params.resolvePlans({ + cfg: input.config, + env: input.env, + stateDir: input.stateDir, + oauthDir: input.oauthDir, + })) ?? []; + const resolvedPlans: ChannelLegacyStateMigrationPlan[] = []; + for (const plan of plans) { + resolvedPlans.push( + plan.kind === "plugin-state-import" && !plan.stateDir + ? { ...plan, stateDir: input.stateDir } + : plan, + ); + } + return resolvedPlans; + }; + + return { + id: params.id, + label: params.label, + ...(params.doctorOnly === true ? { doctorOnly: true } : {}), + async detectLegacyState(input) { + const plans = await resolvePlans(input); + return plans.length > 0 + ? { preview: plans.map((plan) => buildLegacyMigrationPreview(plan)) } + : null; + }, + async migrateLegacyState(input) { + const plans = await resolvePlans(input); + const { runLegacyMigrationPlans } = await import("../infra/state-migrations.plugin-state.js"); + return await runLegacyMigrationPlans(plans); + }, + }; +} diff --git a/src/plugin-sdk/runtime-doctor-migrations.test.ts b/src/plugin-sdk/runtime-doctor-migrations.test.ts new file mode 100644 index 000000000000..a0891fbb6be5 --- /dev/null +++ b/src/plugin-sdk/runtime-doctor-migrations.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/legacy-state-migration.types.js"; +import { definePluginDoctorMigrationFromPlans } from "./runtime-doctor-migrations.js"; + +const runLegacyMigrationPlans = vi.hoisted(() => vi.fn()); +const executorModuleLoads = vi.hoisted(() => vi.fn()); + +vi.mock("../infra/state-migrations.plugin-state.js", () => { + executorModuleLoads(); + return { runLegacyMigrationPlans }; +}); + +const migrationInput = { + config: {}, + env: {}, + stateDir: "/state", + oauthDir: "/oauth", + context: { openPluginStateKeyedStore: vi.fn() } as never, +}; + +describe("definePluginDoctorMigrationFromPlans", () => { + it("maps previews and delegates normalized plans to the existing executor", async () => { + const plans: ChannelLegacyStateMigrationPlan[] = [ + { + kind: "plugin-state-import", + label: "Cache", + sourcePath: "/state/cache.json", + targetPath: "plugin state:cache", + pluginId: "demo", + namespace: "cache", + maxEntries: 10, + scopeKey: "", + readEntries: () => [], + }, + { + kind: "move", + label: "Credentials", + sourcePath: "/oauth/creds.json", + targetPath: "/oauth/demo/creds.json", + }, + { + kind: "copy", + label: "Backup", + sourcePath: "/state/backup.json", + targetPath: "/state/demo/backup.json", + }, + ]; + const migration = definePluginDoctorMigrationFromPlans({ + id: "demo-state", + label: "Demo state", + resolvePlans: () => plans, + }); + + await expect(migration.detectLegacyState(migrationInput)).resolves.toEqual({ + preview: [ + "- Cache: /state/cache.json", + "- Credentials: /oauth/creds.json → /oauth/demo/creds.json", + "- Backup: /state/backup.json → /state/demo/backup.json", + ], + }); + expect(executorModuleLoads).not.toHaveBeenCalled(); + + runLegacyMigrationPlans.mockResolvedValueOnce({ + changes: ["migrated"], + warnings: ["warning"], + }); + await expect(migration.migrateLegacyState(migrationInput)).resolves.toEqual({ + changes: ["migrated"], + warnings: ["warning"], + }); + expect(executorModuleLoads).toHaveBeenCalledTimes(1); + expect(runLegacyMigrationPlans).toHaveBeenCalledTimes(1); + expect(runLegacyMigrationPlans.mock.calls[0]?.[0]).toEqual([ + { ...plans[0], stateDir: "/state" }, + plans[1], + plans[2], + ]); + }); + + it("returns null when no legacy plans resolve", async () => { + const migration = definePluginDoctorMigrationFromPlans({ + id: "empty-state", + label: "Empty state", + resolvePlans: () => [], + }); + + await expect(migration.detectLegacyState(migrationInput)).resolves.toBeNull(); + }); +}); diff --git a/src/plugin-sdk/runtime-doctor-migrations.ts b/src/plugin-sdk/runtime-doctor-migrations.ts index 7a2007e85053..0c9c71019132 100644 --- a/src/plugin-sdk/runtime-doctor-migrations.ts +++ b/src/plugin-sdk/runtime-doctor-migrations.ts @@ -10,7 +10,7 @@ import { asObjectRecord } from "../config/channel-compat-normalization.js"; import type { CompatMutationResult } from "../config/channel-compat-normalization.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OpenKeyedStoreOptions } from "../plugin-state/plugin-state-store.js"; -import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-registry.js"; +import type { PluginDoctorStateMigration } from "../plugins/doctor-contract-module.js"; import { archiveLegacyStateSource } from "../plugins/doctor-state-migration-fs.js"; export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; @@ -48,11 +48,13 @@ export type { export type { PluginDoctorStateMigration, PluginDoctorStateMigrationContext, -} from "../plugins/doctor-contract-registry.js"; +} from "../plugins/doctor-contract-module.js"; export { archiveLegacyStateSource, legacyStateFileExists, } from "../plugins/doctor-state-migration-fs.js"; +export { buildLegacyMigrationPreview } from "../channels/plugins/legacy-state-migration-preview.js"; +export { definePluginDoctorMigrationFromPlans } from "./doctor-migration-plan-adapter.js"; export type { DoctorSessionRouteStateOwner } from "../plugins/doctor-session-route-state-owner-types.js"; type KeyMoveValue = { value: unknown }; diff --git a/src/plugin-sdk/runtime-doctor.ts b/src/plugin-sdk/runtime-doctor.ts index f15f84c9f3bf..817466610f0a 100644 --- a/src/plugin-sdk/runtime-doctor.ts +++ b/src/plugin-sdk/runtime-doctor.ts @@ -14,6 +14,8 @@ export { defineChannelAliasMigration, defineKeyMoveMigration, defineLegacyJsonStateMigration, + definePluginDoctorMigrationFromPlans, + buildLegacyMigrationPreview, hasLegacyAccountStreamingAliases, hasLegacyStreamingAliases, legacyStateFileExists, diff --git a/src/plugins/doctor-contract-closure-guard.test.ts b/src/plugins/doctor-contract-closure-guard.test.ts index 8f7084213ead..4291d3cd3bb7 100644 --- a/src/plugins/doctor-contract-closure-guard.test.ts +++ b/src/plugins/doctor-contract-closure-guard.test.ts @@ -222,6 +222,42 @@ function collectForbiddenClosureImports(entry: ClosureEntry): string[] { return violations; } +function collectHeavyRuntimeDoctorMigrationImports(): string[] { + const entryPath = path.join(REPO_ROOT, "src/plugin-sdk/runtime-doctor-migrations.ts"); + const forbiddenPrefixes = ["src/plugin-state/plugin-state-store", "src/state/openclaw-state-db"]; + const violations: string[] = []; + const visited = new Set(); + const pending = [entryPath]; + + while (pending.length > 0) { + const filePath = pending.pop(); + if (!filePath || visited.has(filePath)) { + continue; + } + visited.add(filePath); + const source = fs.readFileSync(filePath, "utf8"); + for (const reference of collectStaticValueReferences(filePath, source)) { + if (!reference.specifier.startsWith(".")) { + continue; + } + const resolvedPath = resolveRelativeSourceModule(filePath, reference.specifier); + if (!resolvedPath || !isInsideRoot(REPO_ROOT, resolvedPath)) { + continue; + } + const repoPath = formatRepoPath(resolvedPath); + if (forbiddenPrefixes.some((prefix) => repoPath.startsWith(prefix))) { + violations.push( + `${formatRepoPath(filePath)}:${reference.line} reaches heavy doctor dependency ${repoPath}`, + ); + continue; + } + pending.push(resolvedPath); + } + } + + return violations; +} + describe("doctor contract import closures", () => { it("classifies only static value module edges", () => { const source = [ @@ -245,4 +281,8 @@ describe("doctor contract import closures", () => { const violations = collectClosureEntries().flatMap(collectForbiddenClosureImports).toSorted(); expect(violations).toStrictEqual([]); }); + + it("keeps the runtime doctor migration helper off state DB and plugin-state graphs", () => { + expect(collectHeavyRuntimeDoctorMigrationImports()).toStrictEqual([]); + }); }); diff --git a/src/plugins/doctor-contract-declarations.test.ts b/src/plugins/doctor-contract-declarations.test.ts index f6f4443f8caa..87c4019d3766 100644 --- a/src/plugins/doctor-contract-declarations.test.ts +++ b/src/plugins/doctor-contract-declarations.test.ts @@ -9,8 +9,7 @@ import { } from "./plugin-module-loader-cache.js"; const DOCTOR_CONTRACT_SURFACES = [ - "legacyConfigRules", - "normalizeCompatibilityConfig", + "configRepair", "resolveSessionStoreAgentIds", "sessionRouteStateOwners", "stateMigrations", @@ -38,6 +37,12 @@ describe("bundled plugin doctor contract declarations", () => { })(artifactPath) as Parameters[0]; const { summary } = coercePluginDoctorContractModule(mod); for (const surface of DOCTOR_CONTRACT_SURFACES) { + if (surface === "sessionRouteStateOwners" && record.sessionRouteStateOwners !== undefined) { + if (summary.sessionRouteStateOwners) { + mismatches.push(`${record.id}: bundled owner metadata must use the manifest`); + } + continue; + } const declared = declaration[surface] === true; if (declared !== summary[surface]) { mismatches.push( diff --git a/src/plugins/doctor-contract-module.ts b/src/plugins/doctor-contract-module.ts index 8c4bcb4fb976..63f0e0627482 100644 --- a/src/plugins/doctor-contract-module.ts +++ b/src/plugins/doctor-contract-module.ts @@ -1,11 +1,10 @@ -import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import type { LegacyConfigRule } from "../config/legacy.shared.js"; import type { OpenClawConfig } from "../config/types.js"; import type { OpenKeyedStoreOptions, PluginStateKeyedStore, } from "../plugin-state/plugin-state-store.js"; -import type { DoctorSessionRouteStateOwner } from "./doctor-session-route-state-owner-types.js"; +import { coerceDoctorSessionRouteStateOwners } from "./doctor-session-route-state-owner-types.js"; import type { PluginManifestDoctorContract } from "./manifest-types.js"; export type PluginDoctorStateMigrationDetection = { @@ -53,6 +52,10 @@ export type PluginDoctorContractModule = { legacyConfigRules?: unknown; normalizeCompatibilityConfig?: unknown; resolveSessionStoreAgentIds?: unknown; + /** + * @deprecated Declare static ownership in openclaw.plugin.json sessionRouteStateOwners. + * Removal plan: remove the module fallback in OpenClaw 2027.1 after external plugins migrate. + */ sessionRouteStateOwners?: unknown; stateMigrations?: unknown; }; @@ -93,48 +96,6 @@ function coerceSessionStoreAgentIdsResolver( : undefined; } -function isDoctorSessionRouteStateOwner(value: unknown): value is DoctorSessionRouteStateOwner { - if (!value || typeof value !== "object") { - return false; - } - const candidate = value as { - id?: unknown; - label?: unknown; - providerIds?: unknown; - runtimeIds?: unknown; - cliSessionKeys?: unknown; - authProfilePrefixes?: unknown; - }; - return ( - typeof candidate.id === "string" && - typeof candidate.label === "string" && - candidate.id.trim().length > 0 && - candidate.label.trim().length > 0 && - (candidate.providerIds === undefined || - normalizeTrimmedStringList(candidate.providerIds).length > 0) && - (candidate.runtimeIds === undefined || - normalizeTrimmedStringList(candidate.runtimeIds).length > 0) && - (candidate.cliSessionKeys === undefined || - normalizeTrimmedStringList(candidate.cliSessionKeys).length > 0) && - (candidate.authProfilePrefixes === undefined || - normalizeTrimmedStringList(candidate.authProfilePrefixes).length > 0) - ); -} - -function coerceDoctorSessionRouteStateOwners(value: unknown): DoctorSessionRouteStateOwner[] { - if (!Array.isArray(value)) { - return []; - } - return value.filter(isDoctorSessionRouteStateOwner).map((owner) => ({ - id: owner.id.trim(), - label: owner.label.trim(), - providerIds: normalizeTrimmedStringList(owner.providerIds), - runtimeIds: normalizeTrimmedStringList(owner.runtimeIds), - cliSessionKeys: normalizeTrimmedStringList(owner.cliSessionKeys), - authProfilePrefixes: normalizeTrimmedStringList(owner.authProfilePrefixes), - })); -} - function isPluginDoctorStateMigration(value: unknown): value is PluginDoctorStateMigration { if (!value || typeof value !== "object") { return false; @@ -185,8 +146,7 @@ export function coercePluginDoctorContractModule(mod: PluginDoctorContractModule mod.stateMigrations ?? defaultExport?.stateMigrations, ); const summary: Record = { - legacyConfigRules: rules.length > 0, - normalizeCompatibilityConfig: Boolean(normalizeCompatibilityConfig), + configRepair: rules.length > 0 || Boolean(normalizeCompatibilityConfig), resolveSessionStoreAgentIds: Boolean(resolveSessionStoreAgentIds), sessionRouteStateOwners: sessionRouteStateOwners.length > 0, stateMigrations: stateMigrations.length > 0, diff --git a/src/plugins/doctor-contract-registry.load-paths.test.ts b/src/plugins/doctor-contract-registry.load-paths.test.ts index 9e1ad351a297..d3da9825cef1 100644 --- a/src/plugins/doctor-contract-registry.load-paths.test.ts +++ b/src/plugins/doctor-contract-registry.load-paths.test.ts @@ -154,6 +154,16 @@ function writeDoctorSessionOwnerPlugin(pluginRoot: string, pluginId: string): vo name: "Load Path Session Owner", version: "0.0.0-test", configSchema: {}, + sessionRouteStateOwners: [ + { + id: "load-path-session-owner", + label: "Load Path Session Owner", + providerIds: ["load-path-provider"], + runtimeIds: ["load-path-runtime"], + cliSessionKeys: ["load-path-cli"], + authProfilePrefixes: ["load-path:"], + }, + ], }, null, 2, @@ -161,20 +171,24 @@ function writeDoctorSessionOwnerPlugin(pluginRoot: string, pluginId: string): vo "utf8", ); fs.writeFileSync(path.join(pluginRoot, "index.cjs"), "module.exports = {};\n", "utf8"); +} + +function writeLegacyDoctorSessionOwnerPlugin(pluginRoot: string, pluginId: string): void { + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify({ id: pluginId, configSchema: {} }), + "utf8", + ); + fs.writeFileSync(path.join(pluginRoot, "index.cjs"), "module.exports = {};\n", "utf8"); fs.writeFileSync( path.join(pluginRoot, "doctor-contract-api.cjs"), - ` -module.exports = { - sessionRouteStateOwners: [ - { - id: "load-path-session-owner", - label: "Load Path Session Owner", - providerIds: ["load-path-provider"], - runtimeIds: ["load-path-runtime"], - cliSessionKeys: ["load-path-cli"], - authProfilePrefixes: ["load-path:"], - }, - ], + `module.exports = { + sessionRouteStateOwners: [{ + id: "legacy-load-path-owner", + label: "Legacy Load Path Owner", + providerIds: ["legacy-provider"], + }], }; `, "utf8", @@ -306,4 +320,28 @@ describe("doctor contract registry load-path plugins", () => { }, ]); }); + + it("keeps the deprecated module owner route for external load-path plugins", () => { + const stateDir = makeTempDir(); + const pluginRoot = makeTempDir(); + const pluginId = "legacy-load-path-owner"; + writeLegacyDoctorSessionOwnerPlugin(pluginRoot, pluginId); + const config = createDoctorPluginConfig(pluginRoot, pluginId); + + expect( + listPluginDoctorSessionRouteStateOwners({ + config, + env: makeHermeticDoctorEnv(stateDir), + }), + ).toEqual([ + { + id: "legacy-load-path-owner", + label: "Legacy Load Path Owner", + providerIds: ["legacy-provider"], + runtimeIds: [], + cliSessionKeys: [], + authProfilePrefixes: [], + }, + ]); + }); }); diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index 98d88f6138f5..785d8739c8bd 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BundledChannelLegacyStateMigrationDetector } from "../plugin-sdk/channel-entry-contract.types.js"; import { withMockedPlatform } from "../test-utils/vitest-spies.js"; import { resolvePluginDoctorContractArtifactPath } from "./doctor-contract-artifact.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; @@ -14,6 +15,18 @@ import { const tempDirs: string[] = []; const mocks = getRegistryJitiMocks(); const doctorContractWarnMock = vi.hoisted(() => vi.fn()); +const listLegacyChannelMigrationEntriesMock = vi.hoisted(() => + vi.fn< + (options?: { config?: unknown; pluginIds?: readonly string[] }) => Array<{ + pluginId: string; + detector: BundledChannelLegacyStateMigrationDetector; + }> + >(() => []), +); + +vi.mock("../channels/plugins/bundled.js", () => ({ + listBundledChannelLegacyStateMigrationDetectorEntries: listLegacyChannelMigrationEntriesMock, +})); vi.mock("../logging/subsystem.js", async (importOriginal) => { const actual = await importOriginal(); @@ -33,6 +46,7 @@ let collectRelevantDoctorPluginIdsForTouchedPaths: typeof import("./doctor-contr let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules; let listPluginDoctorSessionRouteStateOwners: typeof import("./doctor-contract-registry.js").listPluginDoctorSessionRouteStateOwners; let listPluginDoctorSessionStoreAgentIds: typeof import("./doctor-contract-registry.js").listPluginDoctorSessionStoreAgentIds; +let listPluginDoctorStateMigrationEntries: typeof import("./doctor-contract-registry.js").listPluginDoctorStateMigrationEntries; let setPluginDoctorContractRegistryModuleLoaderFactoryForTest: | typeof import("./doctor-contract-registry.test-fixtures.js").setPluginDoctorContractRegistryModuleLoaderFactoryForTest | undefined; @@ -58,6 +72,8 @@ describe("doctor-contract-registry module loader", () => { beforeEach(async () => { resetRegistryJitiMocks(); doctorContractWarnMock.mockReset(); + listLegacyChannelMigrationEntriesMock.mockReset(); + listLegacyChannelMigrationEntriesMock.mockReturnValue([]); vi.resetModules(); ({ applyPluginDoctorCompatibilityMigrations, @@ -66,6 +82,7 @@ describe("doctor-contract-registry module loader", () => { listPluginDoctorLegacyConfigRules, listPluginDoctorSessionRouteStateOwners, listPluginDoctorSessionStoreAgentIds, + listPluginDoctorStateMigrationEntries, } = await import("./doctor-contract-registry.js")); ({ clearPluginDoctorContractRegistryCache, @@ -104,7 +121,7 @@ describe("doctor-contract-registry module loader", () => { it.each([ { name: "declared false skips loading", - doctorContract: { legacyConfigRules: false }, + doctorContract: { configRepair: false }, expectedRuleCount: 0, expectedLoadCount: 0, }, @@ -116,11 +133,11 @@ describe("doctor-contract-registry module loader", () => { }, { name: "declared true loads the authoritative module", - doctorContract: { legacyConfigRules: true }, + doctorContract: { configRepair: true }, expectedRuleCount: 1, expectedLoadCount: 1, }, - ])("gates doctor contract artifacts by surface: $name", (testCase) => { + ])("gates config-repair artifacts: $name", (testCase) => { const pluginRoot = makeTempDir(); fs.writeFileSync(path.join(pluginRoot, "doctor-contract-api.ts"), "export {};\n", "utf-8"); mocks.createJiti.mockImplementation(() => () => ({ @@ -143,6 +160,33 @@ describe("doctor-contract-registry module loader", () => { expect(mocks.createJiti).toHaveBeenCalledTimes(testCase.expectedLoadCount); }); + it("loads a normalizer-only config-repair contract", () => { + const pluginRoot = makeTempDir(); + fs.writeFileSync(path.join(pluginRoot, "doctor-contract-api.ts"), "export {};\n", "utf-8"); + mocks.createJiti.mockImplementation(() => () => ({ + normalizeCompatibilityConfig: ({ cfg }: { cfg: Record }) => ({ + config: { ...cfg, repaired: true }, + changes: ["repaired config"], + }), + })); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "normalizer-only", + rootDir: pluginRoot, + doctorContract: { configRepair: true }, + }, + ], + diagnostics: [], + }); + + expect(applyPluginDoctorCompatibilityMigrations({}, { env: {} })).toEqual({ + config: { repaired: true }, + changes: ["repaired config"], + }); + expect(mocks.createJiti).toHaveBeenCalledTimes(1); + }); + it("records doctor contract load failures with plugin and artifact context", () => { const pluginRoot = makeTempDir(); const contractSource = path.join(pluginRoot, "doctor-contract-api.ts"); @@ -155,7 +199,7 @@ describe("doctor-contract-registry module loader", () => { { id: "broken-doctor-plugin", rootDir: pluginRoot, - doctorContract: { legacyConfigRules: true }, + doctorContract: { configRepair: true }, }, ], diagnostics: [], @@ -298,21 +342,30 @@ describe("doctor-contract-registry module loader", () => { }); }); - it("loads session route-state owners from doctor contract modules", () => { - const pluginRoot = makeTempDir(); - fs.writeFileSync( - path.join(pluginRoot, "doctor-contract-api.cjs"), - "module.exports = { sessionRouteStateOwners: [{ id: 'demo', label: 'Demo', providerIds: ['demo'], runtimeIds: ['demo-cli'], cliSessionKeys: ['demo-cli'], authProfilePrefixes: ['demo:'] }] };\n", - "utf-8", - ); + it("loads session route-state owners from manifest records without loading modules", () => { mocks.loadPluginManifestRegistry.mockReturnValue({ - plugins: [{ id: "test-plugin", rootDir: pluginRoot }], + plugins: [ + { + id: "test-plugin", + rootDir: "/plugins/test-plugin", + sessionRouteStateOwners: [ + { + id: "demo", + label: "Demo", + providerIds: ["demo"], + runtimeIds: ["demo-cli"], + cliSessionKeys: ["demo-cli"], + authProfilePrefixes: ["demo:"], + }, + ], + }, + ], diagnostics: [], }); expect( listPluginDoctorSessionRouteStateOwners({ - workspaceDir: pluginRoot, + workspaceDir: "/workspace", env: {}, }), ).toEqual([ @@ -325,6 +378,7 @@ describe("doctor-contract-registry module loader", () => { authProfilePrefixes: ["demo:"], }, ]); + expect(mocks.createJiti).not.toHaveBeenCalled(); }); it("loads config-derived session-store agent IDs from doctor contract modules", () => { @@ -351,23 +405,94 @@ describe("doctor-contract-registry module loader", () => { ).toEqual(["cards", "voice"]); }); - it("loads multiple bundled CLI route-state owners from doctor contract modules", () => { - const anthropicRoot = makeTempDir(); - const googleRoot = makeTempDir(); - fs.writeFileSync( - path.join(anthropicRoot, "doctor-contract-api.cjs"), - "module.exports = { sessionRouteStateOwners: [{ id: 'anthropic', label: 'Anthropic', providerIds: ['anthropic', 'claude-cli'], runtimeIds: ['claude-cli'], cliSessionKeys: ['claude-cli'], authProfilePrefixes: ['anthropic:', 'claude-cli:'] }] };\n", - "utf-8", - ); - fs.writeFileSync( - path.join(googleRoot, "doctor-contract-api.cjs"), - "module.exports = { sessionRouteStateOwners: [{ id: 'google', label: 'Google', providerIds: ['google', 'google-antigravity', 'google-gemini-cli', 'google-vertex'], runtimeIds: ['google-gemini-cli'], cliSessionKeys: ['google-gemini-cli', 'gemini-cli'], authProfilePrefixes: ['google:', 'google-antigravity:', 'google-gemini-cli:', 'google-vertex:', 'gemini-cli:'] }] };\n", - "utf-8", - ); + it("adapts deprecated channel detectors into scoped plugin migrations", async () => { + const detector = vi.fn(() => [ + { + kind: "move" as const, + label: "Legacy credentials", + sourcePath: "/oauth/legacy.json", + targetPath: "/oauth/demo/legacy.json", + }, + ]); + listLegacyChannelMigrationEntriesMock.mockReturnValue([ + { pluginId: "legacy-channel", detector }, + ]); + mocks.loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] }); + + const entries = listPluginDoctorStateMigrationEntries({ + config: {}, + env: {}, + pluginIds: ["legacy-channel"], + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.pluginId).toBe("legacy-channel"); + await expect( + entries[0]?.migration.detectLegacyState({ + config: {}, + env: {}, + stateDir: "/state", + oauthDir: "/oauth", + context: { openPluginStateKeyedStore: vi.fn() } as never, + }), + ).resolves.toEqual({ + preview: ["- Legacy credentials: /oauth/legacy.json → /oauth/demo/legacy.json"], + }); + expect(detector).toHaveBeenCalledTimes(1); + expect(listLegacyChannelMigrationEntriesMock).toHaveBeenCalledWith({ + config: {}, + pluginIds: ["legacy-channel"], + }); + }); + + it("deduplicates manifest owners by first id and sorts them by id", () => { mocks.loadPluginManifestRegistry.mockReturnValue({ plugins: [ - { id: "anthropic", rootDir: anthropicRoot }, - { id: "google", rootDir: googleRoot }, + { + id: "google", + rootDir: "/plugins/google", + channels: [], + providers: ["google"], + sessionRouteStateOwners: [ + { + id: "google", + label: "Google", + providerIds: ["google", "google-antigravity", "google-gemini-cli", "google-vertex"], + runtimeIds: ["google-gemini-cli"], + cliSessionKeys: ["google-gemini-cli", "gemini-cli"], + authProfilePrefixes: [ + "google:", + "google-antigravity:", + "google-gemini-cli:", + "google-vertex:", + "gemini-cli:", + ], + }, + ], + }, + { + id: "anthropic", + rootDir: "/plugins/anthropic", + channels: [], + providers: ["anthropic"], + sessionRouteStateOwners: [ + { + id: "anthropic", + label: "Anthropic", + providerIds: ["anthropic", "claude-cli"], + runtimeIds: ["claude-cli"], + cliSessionKeys: ["claude-cli"], + authProfilePrefixes: ["anthropic:", "claude-cli:"], + }, + ], + }, + { + id: "google-shadow", + rootDir: "/plugins/google-shadow", + channels: [], + providers: ["google-shadow"], + sessionRouteStateOwners: [{ id: "google", label: "Ignored duplicate" }], + }, ], diagnostics: [], }); @@ -376,7 +501,7 @@ describe("doctor-contract-registry module loader", () => { listPluginDoctorSessionRouteStateOwners({ workspaceDir: "/workspace", env: {}, - pluginIds: ["anthropic", "google"], + pluginIds: ["anthropic", "google", "google-shadow"], }), ).toEqual([ { @@ -402,6 +527,7 @@ describe("doctor-contract-registry module loader", () => { ], }, ]); + expect(mocks.createJiti).not.toHaveBeenCalled(); }); it("passes active config to manifest registry discovery", () => { diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index 156a35431b43..84a457409ede 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -2,10 +2,12 @@ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; +import { listBundledChannelLegacyStateMigrationDetectorEntries } from "../channels/plugins/bundled.js"; import type { LegacyConfigRule } from "../config/legacy.shared.js"; import type { OpenClawConfig } from "../config/types.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { definePluginDoctorMigrationFromPlans } from "../plugin-sdk/doctor-migration-plan-adapter.js"; import { resolvePluginDoctorContractArtifactPath } from "./doctor-contract-artifact.js"; import { coercePluginDoctorContractModule, @@ -208,13 +210,12 @@ function loadPluginDoctorContractEntry( }; } -function resolvePluginDoctorContracts(params: { - surface: PluginDoctorContractSurface; +function resolvePluginDoctorManifestRecords(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; -}): PluginDoctorContractEntry[] { +}): PluginManifestRegistryRecord[] { const env = params?.env ?? process.env; if (params?.pluginIds && params.pluginIds.length === 0) { return []; @@ -227,19 +228,39 @@ function resolvePluginDoctorContracts(params: { includeDisabled: true, }); - const entries: PluginDoctorContractEntry[] = []; const scopedPluginIds = params?.pluginIds ? new Set(params.pluginIds) : null; - for (const record of manifestRegistry.plugins) { - if ( - scopedPluginIds && - !scopedPluginIds.has(record.id) && - !(record.packageName && scopedPluginIds.has(record.packageName)) && - !record.legacyPluginIds?.some((pluginId) => scopedPluginIds.has(pluginId)) && - !record.channels.some((channelId) => scopedPluginIds.has(channelId)) && - !record.providers.some((providerId) => scopedPluginIds.has(providerId)) - ) { - continue; - } + return manifestRegistry.plugins.filter( + (record) => + !( + scopedPluginIds && + !scopedPluginIds.has(record.id) && + !(record.packageName && scopedPluginIds.has(record.packageName)) && + !record.legacyPluginIds?.some((pluginId) => scopedPluginIds.has(pluginId)) && + !record.channels.some((channelId) => scopedPluginIds.has(channelId)) && + !record.providers.some((providerId) => scopedPluginIds.has(providerId)) + ), + ); +} + +function resolvePluginDoctorContracts(params: { + surface: PluginDoctorContractSurface; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; +}): PluginDoctorContractEntry[] { + return loadPluginDoctorContractEntries({ + records: resolvePluginDoctorManifestRecords(params), + surface: params.surface, + }); +} + +function loadPluginDoctorContractEntries(params: { + records: PluginManifestRegistryRecord[]; + surface: PluginDoctorContractSurface; +}): PluginDoctorContractEntry[] { + const entries: PluginDoctorContractEntry[] = []; + for (const record of params.records) { const declaration = record.doctorContract; // Declarations gate loading only; modules remain authoritative, while absence preserves loading. if (declaration && declaration[params.surface] !== true) { @@ -261,7 +282,7 @@ export function listPluginDoctorLegacyConfigRules(params?: { }): LegacyConfigRule[] { return resolvePluginDoctorContracts({ ...params, - surface: "legacyConfigRules", + surface: "configRepair", }).flatMap((entry) => entry.rules); } @@ -272,10 +293,13 @@ export function listPluginDoctorSessionRouteStateOwners(params?: { pluginIds?: readonly string[]; }): DoctorSessionRouteStateOwner[] { const owners = new Map(); - for (const owner of resolvePluginDoctorContracts({ - ...params, + const records = resolvePluginDoctorManifestRecords(params ?? {}); + const manifestOwners = records.flatMap((record) => record.sessionRouteStateOwners ?? []); + const legacyModuleOwners = loadPluginDoctorContractEntries({ + records: records.filter((record) => record.sessionRouteStateOwners === undefined), surface: "sessionRouteStateOwners", - }).flatMap((entry) => entry.sessionRouteStateOwners)) { + }).flatMap((entry) => entry.sessionRouteStateOwners); + for (const owner of [...manifestOwners, ...legacyModuleOwners]) { if (!owners.has(owner.id)) { owners.set(owner.id, owner); } @@ -316,12 +340,29 @@ export function listPluginDoctorStateMigrationEntries(params?: { env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): PluginDoctorStateMigrationEntry[] { - return resolvePluginDoctorContracts({ ...params, surface: "stateMigrations" }).flatMap((entry) => + const declaredEntries = resolvePluginDoctorContracts({ + ...params, + surface: "stateMigrations", + }).flatMap((entry) => entry.stateMigrations.map((migration) => ({ pluginId: entry.pluginId, migration, })), ); + // Shipped channel setup entries may still declare migration detectors. Keep this + // single bridge until the 2027.1 external-plugin migration window closes. + const legacyEntries = listBundledChannelLegacyStateMigrationDetectorEntries({ + config: params?.config, + pluginIds: params?.pluginIds, + }).map(({ pluginId, detector }) => ({ + pluginId, + migration: definePluginDoctorMigrationFromPlans({ + id: `${pluginId}-legacy-channel-state`, + label: `${pluginId} legacy channel state`, + resolvePlans: detector, + }), + })); + return [...declaredEntries, ...legacyEntries]; } export function applyPluginDoctorCompatibilityMigrations( @@ -340,7 +381,7 @@ export function applyPluginDoctorCompatibilityMigrations( const changes: string[] = []; for (const entry of resolvePluginDoctorContracts({ ...params, - surface: "normalizeCompatibilityConfig", + surface: "configRepair", })) { const mutation = entry.normalizeCompatibilityConfig?.({ cfg: nextCfg }); if (!mutation || mutation.changes.length === 0) { diff --git a/src/plugins/doctor-session-route-state-owner-types.ts b/src/plugins/doctor-session-route-state-owner-types.ts index 970ec538beca..9227e12f0914 100644 --- a/src/plugins/doctor-session-route-state-owner-types.ts +++ b/src/plugins/doctor-session-route-state-owner-types.ts @@ -1,4 +1,6 @@ -// Defines doctor session route state ownership types for plugin repairs. +// Defines and normalizes doctor session route state ownership for plugin repairs. +import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; + export type DoctorSessionRouteStateOwner = { id: string; label: string; @@ -7,3 +9,47 @@ export type DoctorSessionRouteStateOwner = { cliSessionKeys?: readonly string[]; authProfilePrefixes?: readonly string[]; }; + +function isDoctorSessionRouteStateOwner(value: unknown): value is DoctorSessionRouteStateOwner { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as { + id?: unknown; + label?: unknown; + providerIds?: unknown; + runtimeIds?: unknown; + cliSessionKeys?: unknown; + authProfilePrefixes?: unknown; + }; + return ( + typeof candidate.id === "string" && + typeof candidate.label === "string" && + candidate.id.trim().length > 0 && + candidate.label.trim().length > 0 && + (candidate.providerIds === undefined || + normalizeTrimmedStringList(candidate.providerIds).length > 0) && + (candidate.runtimeIds === undefined || + normalizeTrimmedStringList(candidate.runtimeIds).length > 0) && + (candidate.cliSessionKeys === undefined || + normalizeTrimmedStringList(candidate.cliSessionKeys).length > 0) && + (candidate.authProfilePrefixes === undefined || + normalizeTrimmedStringList(candidate.authProfilePrefixes).length > 0) + ); +} + +export function coerceDoctorSessionRouteStateOwners( + value: unknown, +): DoctorSessionRouteStateOwner[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter(isDoctorSessionRouteStateOwner).map((owner) => ({ + id: owner.id.trim(), + label: owner.label.trim(), + providerIds: normalizeTrimmedStringList(owner.providerIds), + runtimeIds: normalizeTrimmedStringList(owner.runtimeIds), + cliSessionKeys: normalizeTrimmedStringList(owner.cliSessionKeys), + authProfilePrefixes: normalizeTrimmedStringList(owner.authProfilePrefixes), + })); +} diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 1fc88cdffe22..6a1badf2004a 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -21,6 +21,7 @@ import { type PluginCandidate, type PluginDiscoveryResult, } from "./discovery.js"; +import type { DoctorSessionRouteStateOwner } from "./doctor-session-route-state-owner-types.js"; import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js"; @@ -249,6 +250,7 @@ export type PluginManifestRecord = { activation?: PluginManifestActivation; setup?: PluginManifestSetup; doctorContract?: PluginManifestDoctorContract; + sessionRouteStateOwners?: DoctorSessionRouteStateOwner[]; packageManifest?: OpenClawPackageManifest; packageDependencies?: PluginDependencySpecMap; packageOptionalDependencies?: PluginDependencySpecMap; @@ -558,6 +560,7 @@ function buildRecord(params: { return { id: pluginId, doctorContract: params.manifest.doctorContract, + sessionRouteStateOwners: params.manifest.sessionRouteStateOwners, name: normalizeOptionalString(params.manifest.name) ?? params.candidate.packageName, description: normalizeOptionalString(params.manifest.description) ?? params.candidate.packageDescription, diff --git a/src/plugins/manifest-types.ts b/src/plugins/manifest-types.ts index 9b040f3a152a..b4f121c7663d 100644 --- a/src/plugins/manifest-types.ts +++ b/src/plugins/manifest-types.ts @@ -2,6 +2,7 @@ import type { ModelCatalog } from "@openclaw/model-catalog-core/model-catalog-ty import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.config.js"; import type { ConfigUiPresentation } from "../shared/config-ui-hints-types.js"; import type { JsonSchemaObject } from "../shared/json-schema.types.js"; +import type { DoctorSessionRouteStateOwner } from "./doctor-session-route-state-owner-types.js"; import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js"; import type { PluginKind } from "./plugin-kind.types.js"; @@ -226,9 +227,12 @@ export type PluginManifestSetup = { }; export type PluginManifestDoctorContract = { - legacyConfigRules?: boolean; - normalizeCompatibilityConfig?: boolean; + configRepair?: boolean; resolveSessionStoreAgentIds?: boolean; + /** + * @deprecated Declare static ownership in top-level sessionRouteStateOwners instead. + * Removal plan: remove the module fallback in OpenClaw 2027.1 after external plugins migrate. + */ sessionRouteStateOwners?: boolean; stateMigrations?: boolean; }; @@ -394,6 +398,8 @@ export type PluginManifest = { setup?: PluginManifestSetup; /** Doctor contract surfaces available without loading the plugin artifact. */ doctorContract?: PluginManifestDoctorContract; + /** Static ownership metadata for doctor session-route state repairs. */ + sessionRouteStateOwners?: DoctorSessionRouteStateOwner[]; /** Cheap QA runner metadata exposed before plugin runtime loads. */ qaRunners?: PluginManifestQaRunner[]; /** Widget data and action capabilities validated against runtime registrations. */ diff --git a/src/plugins/manifest.json5-tolerance.test.ts b/src/plugins/manifest.json5-tolerance.test.ts index 5e3f74a62251..64705d0e9c46 100644 --- a/src/plugins/manifest.json5-tolerance.test.ts +++ b/src/plugins/manifest.json5-tolerance.test.ts @@ -37,6 +37,44 @@ describe("loadPluginManifest JSON5 tolerance", () => { } }); + it("normalizes static doctor session route-state owners", () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, "openclaw.plugin.json"), + JSON.stringify({ + id: "doctor-owners", + configSchema: { type: "object" }, + sessionRouteStateOwners: [ + { + id: " demo ", + label: " Demo owner ", + providerIds: [" demo ", "", "demo"], + }, + { id: "blank-list", label: "Blank list", runtimeIds: [" "] }, + { id: " ", label: "Missing id" }, + null, + ], + }), + "utf-8", + ); + + const result = loadPluginManifest(dir, false); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.manifest.sessionRouteStateOwners).toEqual([ + { + id: "demo", + label: "Demo owner", + providerIds: ["demo", "demo"], + runtimeIds: [], + cliSessionKeys: [], + authProfilePrefixes: [], + }, + ]); + } + }); + it("uses native JSON parsing for standard JSON manifests", () => { const json5Parse = vi.spyOn(JSON5, "parse"); const dir = makeTempDir(); diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index c06544495cfe..8ca494a6ceef 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -7,6 +7,7 @@ import { normalizeTrimmedStringList } from "../../packages/normalization-core/sr import { matchRootFileOpenFailure, openRootFileSync } from "../infra/boundary-file-read.js"; import { isRecord } from "../utils.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; +import { coerceDoctorSessionRouteStateOwners } from "./doctor-session-route-state-owner-types.js"; import * as capabilityNormalizers from "./manifest-capability-normalizers.js"; import { normalizeManifestCommandAliases } from "./manifest-command-aliases.js"; import * as modelProviderNormalizers from "./manifest-model-provider-normalizers.js"; @@ -213,8 +214,7 @@ export function loadPluginManifest( const doctorContract = rawDoctorContract ? (Object.fromEntries( [ - "legacyConfigRules", - "normalizeCompatibilityConfig", + "configRepair", "resolveSessionStoreAgentIds", "sessionRouteStateOwners", "stateMigrations", @@ -269,6 +269,10 @@ export function loadPluginManifest( activation: setupNormalizers.normalizeManifestActivation(raw.activation), setup: setupNormalizers.normalizeManifestSetup(raw.setup), doctorContract, + sessionRouteStateOwners: + raw.sessionRouteStateOwners === undefined + ? undefined + : coerceDoctorSessionRouteStateOwners(raw.sessionRouteStateOwners), qaRunners: setupNormalizers.normalizeManifestQaRunners(raw.qaRunners), }; const dashboardResult = setupNormalizers.normalizeManifestDashboard(raw.dashboard); diff --git a/src/plugins/package-manifest.ts b/src/plugins/package-manifest.ts index f670db38b529..a1de446677fc 100644 --- a/src/plugins/package-manifest.ts +++ b/src/plugins/package-manifest.ts @@ -81,6 +81,10 @@ export type PluginPackageInstall = { type OpenClawPackageSetupFeatures = { configPromotion?: boolean; + /** + * @deprecated Declare doctorContract.stateMigrations in openclaw.plugin.json instead. + * Removal plan: remove the setup-entry adapter after the 2027.1 external-plugin migration window. + */ legacyStateMigrations?: boolean; legacySessionSurfaces?: boolean; };