diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 5d93a9d39310..8dd5ea25a6e8 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -118,7 +118,7 @@ b6b8edc50ecab8386c9acd8f374a207212b5a99c8f518538bbcf0c458dda3881 module/runtime 31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion 9d7d884330397701c7de9f5b6800b970b2b349027491704184cb1bd6cda1fd00 module/session-store-runtime 7cad408673562b0ff33f60071698bea2c39d02c64f4f235851c5c2ceb946910d module/setup -3901204978c50eb5908ac504a9cc1060fb8baa32dad27f6caed7583ca9737a89 module/setup-runtime +fbcb853789db5a1ad5d81e8094ed7f5bcea657ac2281e7887a19c1d6f1194571 module/setup-runtime cd431f6ba8327b81438b7a63b1963120f200f5abd145fb6aa7c5c561339cb0b1 module/setup-tools 18e384ec43d9eaee52c8e286e127bda2048370e2337964a754d94b236724ca9e module/skill-commands-runtime ae469f32799380e6b045abaefefee6eb3f00d714ffbf36b6eeef5025dc529472 module/speech-settings diff --git a/docs/docs_map.md b/docs/docs_map.md index ca34be85ead7..b7ae3d43f332 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -7570,6 +7570,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: What changed - H3: Why - H2: Compatibility policy + - H3: Published channel setup compatibility - H3: Channel setup input field compatibility - H4: Verifying readers - H2: How to migrate diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index bef413512c33..cf5a3a741fbf 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -89,6 +89,21 @@ If a manifest field is still accepted, keep using it until docs and diagnostics say otherwise. New code should prefer the documented replacement; existing plugins should not break during ordinary minor releases. +### Published channel setup compatibility + +Slack, Discord, Signal, and Microsoft Teams packages published through +`2026.7.1` import channel-specific config schemas from +`openclaw/plugin-sdk/bundled-channel-config-schema`. The published Slack and +Discord packages also import `createLegacyCompatChannelDmPolicy` and +`promptLegacyChannelAllowFromForAccount` from +`openclaw/plugin-sdk/setup-runtime`. + +Those exports remain available as deprecated runtime compatibility adapters. +New and republished plugins should own their config schemas and setup policy +locally, using generic primitives from `channel-config-schema` and +`setup-runtime`. The compatibility exports can be removed only after the +minimum supported published package versions no longer import them. + ### Channel setup input field compatibility `ChannelSetupInput` now keeps only the cross-channel setup envelope typed diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 068c305931c8..f01c0fe6a46b 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -126,6 +126,8 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ "channel-policy": 7, "channel-send-result": 1, "session-store-runtime": 4, + // +2: shipped Slack and Discord setup helpers retained through their package migration window. + "setup-runtime": 2, "group-access": 13, "reply-history": 6, "messaging-targets": 12, @@ -156,7 +158,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: outbound formatting capability profile. // +3: plugin approval reviewer-detail cap/truncator and sanitize-with-status variant. // +1: canonical incognito session classifier for storage-safe plugin behavior. - 4695, + // +2: shipped Slack and Discord setup compatibility helpers. + 4697, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -172,13 +175,15 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: agent-harness transcript visibility projector. // +2: plugin approval detail truncator and sanitize-with-status variant. // +1: canonical incognito session classifier for storage-safe plugin behavior. - 2843, + // +2: shipped Slack and Discord setup compatibility helpers. + 2845, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS", // +3: canonical incognito classifier projected through deprecated compatibility barrels. - 1686, + // +2: shipped Slack and Discord setup compatibility helpers. + 1688, env, ), publicWildcardReexports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/channels/plugins/setup-wizard-legacy-compat.test.ts b/src/channels/plugins/setup-wizard-legacy-compat.test.ts new file mode 100644 index 000000000000..69acdfac6caf --- /dev/null +++ b/src/channels/plugins/setup-wizard-legacy-compat.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import { + createLegacyCompatChannelDmPolicy, + promptLegacyChannelAllowFromForAccount, +} from "./setup-wizard-legacy-compat.js"; + +describe("legacy channel setup compatibility", () => { + it("preserves legacy DM policy behavior for shipped setup plugins", () => { + const policy = createLegacyCompatChannelDmPolicy({ label: "Slack", channel: "slack" }); + const initial = { + channels: { + slack: { + dm: { policy: "allowlist" }, + accounts: { work: { dmPolicy: "disabled", allowFrom: ["U1"] } }, + }, + }, + } as OpenClawConfig; + + expect(policy.getCurrent(initial, "work")).toBe("disabled"); + expect(policy.setPolicy(initial, "open", "work")).toMatchObject({ + channels: { + slack: { + accounts: { + work: { dmPolicy: "open", allowFrom: ["U1", "*"] }, + }, + }, + }, + }); + expect(policy.setPolicy(initial, "open", "default")).toMatchObject({ + channels: { + slack: { + dmPolicy: "open", + allowFrom: ["*"], + dm: { policy: "allowlist", enabled: true }, + }, + }, + }); + }); + + it("preserves the legacy allowlist prompt contract", async () => { + const note = vi.fn(async () => undefined); + const text = vi.fn(async () => "U2"); + const prompter = { note, text } as unknown as WizardPrompter; + const cfg = { + channels: { slack: { allowFrom: ["U1"] } }, + } as OpenClawConfig; + + const next = await promptLegacyChannelAllowFromForAccount({ + cfg, + channel: "slack", + prompter, + defaultAccountId: "default", + resolveAccount: () => ({ allowFrom: ["U1"] }), + resolveExisting: (account) => account.allowFrom, + resolveToken: () => null, + noteTitle: "Slack allowlist", + noteLines: ["Enter Slack user ids"], + message: "Allowed users", + placeholder: "U123", + parseId: (value) => (/^U\d+$/.test(value) ? value : null), + invalidWithoutTokenNote: "Use an id", + resolveEntries: async () => [], + }); + + expect(note).toHaveBeenCalledWith("Enter Slack user ids", "Slack allowlist"); + expect(next).toMatchObject({ + channels: { + slack: { allowFrom: ["U1", "U2"], dm: { enabled: true } }, + }, + }); + }); +}); diff --git a/src/channels/plugins/setup-wizard-legacy-compat.ts b/src/channels/plugins/setup-wizard-legacy-compat.ts new file mode 100644 index 000000000000..8a86e6eda18d --- /dev/null +++ b/src/channels/plugins/setup-wizard-legacy-compat.ts @@ -0,0 +1,181 @@ +import type { DmPolicy } from "../../config/types.base.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import { resolveChannelDmAllowFrom, resolveChannelDmPolicy } from "./dm-access.js"; +import { + addWildcardAllowFrom, + patchChannelConfigForAccount, + promptResolvedAllowFrom, + resolveSetupAccountId, + splitSetupEntries, +} from "./setup-wizard-helpers.js"; +import type { ChannelSetupDmPolicy } from "./setup-wizard-types.js"; + +type AllowFromResolution = { + input: string; + resolved: boolean; + id?: string | null; +}; + +function patchLegacyChannelConfig(params: { + cfg: OpenClawConfig; + channel: string; + patch: Record; +}): OpenClawConfig { + const channelConfig = + (params.cfg.channels?.[params.channel] as Record | undefined) ?? {}; + const dmConfig = (channelConfig.dm as Record | undefined) ?? {}; + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [params.channel]: { + ...channelConfig, + ...params.patch, + dm: { + ...dmConfig, + enabled: typeof dmConfig.enabled === "boolean" ? dmConfig.enabled : true, + }, + }, + }, + }; +} + +function setLegacyChannelDmPolicy(params: { + cfg: OpenClawConfig; + channel: string; + dmPolicy: DmPolicy; +}): OpenClawConfig { + const channelConfig = + (params.cfg.channels?.[params.channel] as Record | undefined) ?? {}; + const existingAllowFrom = resolveChannelDmAllowFrom({ account: channelConfig }); + const allowFrom = + params.dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; + return patchLegacyChannelConfig({ + cfg: params.cfg, + channel: params.channel, + patch: { + dmPolicy: params.dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }); +} + +/** @deprecated Compatibility for plugins published before setup policy became plugin-owned. */ +export function createLegacyCompatChannelDmPolicy(params: { + label: string; + channel: string; + promptAllowFrom?: ChannelSetupDmPolicy["promptAllowFrom"]; +}): ChannelSetupDmPolicy { + return { + label: params.label, + channel: params.channel, + policyKey: `channels.${params.channel}.dmPolicy`, + allowFromKey: `channels.${params.channel}.allowFrom`, + resolveConfigKeys: (_cfg, accountId) => + accountId && accountId !== DEFAULT_ACCOUNT_ID + ? { + policyKey: `channels.${params.channel}.accounts.${accountId}.dmPolicy`, + allowFromKey: `channels.${params.channel}.accounts.${accountId}.allowFrom`, + } + : { + policyKey: `channels.${params.channel}.dmPolicy`, + allowFromKey: `channels.${params.channel}.allowFrom`, + }, + getCurrent: (cfg, accountId) => { + const channelConfig = + (cfg.channels?.[params.channel] as + | { + dmPolicy?: DmPolicy; + dm?: { policy?: DmPolicy }; + accounts?: Record; + } + | undefined) ?? {}; + const accountConfig = + accountId && accountId !== DEFAULT_ACCOUNT_ID + ? channelConfig.accounts?.[accountId] + : undefined; + return resolveChannelDmPolicy({ + account: accountConfig as Record | undefined, + parent: channelConfig as Record, + defaultPolicy: "pairing", + }) as DmPolicy; + }, + setPolicy: (cfg, policy, accountId) => + accountId && accountId !== DEFAULT_ACCOUNT_ID + ? patchChannelConfigForAccount({ + cfg, + channel: params.channel, + accountId, + patch: { + dmPolicy: policy, + ...(policy === "open" + ? { + allowFrom: addWildcardAllowFrom( + resolveChannelDmAllowFrom({ + account: ( + cfg.channels?.[params.channel] as + | { accounts?: Record> } + | undefined + )?.accounts?.[accountId], + parent: cfg.channels?.[params.channel] as + | Record + | undefined, + }), + ), + } + : {}), + }, + }) + : setLegacyChannelDmPolicy({ + cfg, + channel: params.channel, + dmPolicy: policy, + }), + ...(params.promptAllowFrom ? { promptAllowFrom: params.promptAllowFrom } : {}), + }; +} + +/** @deprecated Compatibility for plugins published before setup allowlists became plugin-owned. */ +export async function promptLegacyChannelAllowFromForAccount(params: { + cfg: OpenClawConfig; + channel: string; + prompter: WizardPrompter; + accountId?: string; + defaultAccountId: string; + resolveAccount: (cfg: OpenClawConfig, accountId: string) => TAccount; + resolveExisting: (account: TAccount, cfg: OpenClawConfig) => Array; + resolveToken: (account: TAccount) => string | null | undefined; + noteTitle: string; + noteLines: string[]; + message: string; + placeholder: string; + parseId: (value: string) => string | null; + invalidWithoutTokenNote: string; + resolveEntries: (params: { token: string; entries: string[] }) => Promise; +}): Promise { + const accountId = resolveSetupAccountId({ + accountId: params.accountId, + defaultAccountId: params.defaultAccountId, + }); + const account = params.resolveAccount(params.cfg, accountId); + await params.prompter.note(params.noteLines.join("\n"), params.noteTitle); + const allowFrom = await promptResolvedAllowFrom({ + prompter: params.prompter, + existing: params.resolveExisting(account, params.cfg), + token: params.resolveToken(account), + message: params.message, + placeholder: params.placeholder, + label: params.noteTitle, + parseInputs: splitSetupEntries, + parseId: params.parseId, + invalidWithoutTokenNote: params.invalidWithoutTokenNote, + resolveEntries: params.resolveEntries, + }); + return patchLegacyChannelConfig({ + cfg: params.cfg, + channel: params.channel, + patch: { allowFrom }, + }); +} diff --git a/src/claws/package-remove.ts b/src/claws/package-remove.ts index 5006a23600d4..6b619f865669 100644 --- a/src/claws/package-remove.ts +++ b/src/claws/package-remove.ts @@ -373,7 +373,11 @@ export async function applyClawPackageRemovals( return await applyClawPackageRemovalsUnlocked(decisions, options); } return await withPluginLifecycleLease( - {}, + { + ...(options.env ? { env: options.env } : {}), + ...(options.path ? { path: options.path } : {}), + ...(options.database ? { database: options.database } : {}), + }, async () => await applyClawPackageRemovalsUnlocked(decisions, options), ); } diff --git a/src/claws/packages.ts b/src/claws/packages.ts index bc05ace0d6c5..44e08d15e976 100644 --- a/src/claws/packages.ts +++ b/src/claws/packages.ts @@ -226,7 +226,11 @@ export async function installClawPackages( return await installClawPackagesUnlocked(plan, options); } return await withPluginLifecycleLease( - {}, + { + ...(options.env ? { env: options.env } : {}), + ...(options.path ? { path: options.path } : {}), + ...(options.database ? { database: options.database } : {}), + }, async () => await installClawPackagesUnlocked(plan, options), ); } diff --git a/src/cli/plugins-cli.registry.test.ts b/src/cli/plugins-cli.registry.test.ts new file mode 100644 index 000000000000..8e2980f04cc3 --- /dev/null +++ b/src/cli/plugins-cli.registry.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + refreshPluginRegistry, + resetPluginsCliTestState, + runPluginsCommand, +} from "./plugins-cli-test-helpers.js"; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("plugins registry refresh", () => { + beforeEach(() => { + resetPluginsCliTestState(); + }); + + it("serializes registry rebuilds with other plugin lifecycle mutations", async () => { + const firstEntered = deferred(); + const releaseFirst = deferred(); + const entries: number[] = []; + refreshPluginRegistry.mockImplementation(async () => { + const entry = entries.length + 1; + entries.push(entry); + if (entry === 1) { + firstEntered.resolve(); + await releaseFirst.promise; + } + return { plugins: [] }; + }); + + const first = runPluginsCommand(["plugins", "registry", "--refresh", "--json"]); + await firstEntered.promise; + const second = runPluginsCommand(["plugins", "registry", "--refresh", "--json"]); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + expect(entries).toEqual([1]); + + releaseFirst.resolve(); + await Promise.all([first, second]); + expect(entries).toEqual([1, 2]); + }); +}); diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index df526ac87656..08561d5fdad1 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -303,27 +303,27 @@ export async function runPluginsInstallAction( export async function runPluginsRegistryCommand(opts: PluginRegistryOptions): Promise { const { inspectPluginRegistry, refreshPluginRegistry } = await import("../plugins/plugin-registry.js"); - const cfg = getRuntimeConfig(); if (opts.refresh) { - const index = await refreshPluginRegistry({ - config: cfg, - reason: "manual", - }); - if (opts.json) { - defaultRuntime.writeJson({ - refreshed: true, - registry: index, + return await withPluginLifecycleLease({}, async () => { + const index = await refreshPluginRegistry({ + config: getRuntimeConfig(), + reason: "manual", }); - return; - } - const total = index.plugins.length; - const enabled = countEnabledPlugins(index.plugins); - defaultRuntime.log(`Plugin registry refreshed: ${enabled}/${total} enabled plugins indexed.`); - return; + if (opts.json) { + defaultRuntime.writeJson({ + refreshed: true, + registry: index, + }); + return; + } + const total = index.plugins.length; + const enabled = countEnabledPlugins(index.plugins); + defaultRuntime.log(`Plugin registry refreshed: ${enabled}/${total} enabled plugins indexed.`); + }); } - const inspection = await inspectPluginRegistry({ config: cfg }); + const inspection = await inspectPluginRegistry({ config: getRuntimeConfig() }); if (opts.json) { defaultRuntime.writeJson({ state: inspection.state, diff --git a/src/plugin-sdk/bundled-channel-config-schema.ts b/src/plugin-sdk/bundled-channel-config-schema.ts index a5a857b77b94..cf169088b048 100644 --- a/src/plugin-sdk/bundled-channel-config-schema.ts +++ b/src/plugin-sdk/bundled-channel-config-schema.ts @@ -6,7 +6,7 @@ * bundled channel schemas. Internal callers use this subpath only for the * bundled provider schemas; generic primitives come from channel-config-schema. */ -import type { ZodObject, ZodOptional, ZodType } from "zod"; +import { z, type ZodObject, type ZodOptional, type ZodType } from "zod"; import type { OpenClawConfig } from "./config-contracts.js"; import { createLazyFacadeObjectValue, @@ -32,6 +32,24 @@ export { requireOpenAllowFrom, ToolPolicySchema, } from "./channel-config-schema.js"; + +function createLegacyExternalChannelConfigSchema() { + return z.object({}).passthrough(); +} + +/** + * @deprecated Compatibility for external channel packages published through 2026.7.1. + * Their package manifests remain the validation owner. Remove after the minimum supported + * Slack, Discord, Signal, and Teams packages use plugin-owned config schemas. + */ +export const SlackConfigSchema = createLegacyExternalChannelConfigSchema(); +/** @deprecated See SlackConfigSchema. */ +export const DiscordConfigSchema = createLegacyExternalChannelConfigSchema(); +/** @deprecated See SlackConfigSchema. */ +export const SignalConfigSchema = createLegacyExternalChannelConfigSchema(); +/** @deprecated See SlackConfigSchema. */ +export const MSTeamsConfigSchema = createLegacyExternalChannelConfigSchema(); + type ChannelConfig = NonNullable; type ConfigSchemaShape = { -readonly [K in keyof TOutput]-?: Pick extends Required> diff --git a/src/plugin-sdk/setup-runtime.ts b/src/plugin-sdk/setup-runtime.ts index 31a6c825859d..d3fafc99b6a3 100644 --- a/src/plugin-sdk/setup-runtime.ts +++ b/src/plugin-sdk/setup-runtime.ts @@ -43,6 +43,10 @@ export { splitSetupEntries, } from "../channels/plugins/setup-wizard-helpers.js"; +export { + createLegacyCompatChannelDmPolicy, + promptLegacyChannelAllowFromForAccount, +} from "../channels/plugins/setup-wizard-legacy-compat.js"; export { createAllowlistSetupWizardProxy } from "../channels/plugins/setup-wizard-proxy.js"; export { createCliPathTextInput, diff --git a/src/plugin-sdk/shipped-channel-compat.test.ts b/src/plugin-sdk/shipped-channel-compat.test.ts new file mode 100644 index 000000000000..d745c99f767b --- /dev/null +++ b/src/plugin-sdk/shipped-channel-compat.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + DiscordConfigSchema, + MSTeamsConfigSchema, + SignalConfigSchema, + SlackConfigSchema, +} from "./bundled-channel-config-schema.js"; +import { + createLegacyCompatChannelDmPolicy, + promptLegacyChannelAllowFromForAccount, +} from "./setup-runtime.js"; + +describe("shipped external channel compatibility", () => { + it("retains named config schema exports used by published channel packages", () => { + for (const schema of [ + SlackConfigSchema, + DiscordConfigSchema, + SignalConfigSchema, + MSTeamsConfigSchema, + ]) { + expect(schema.safeParse({ legacySetting: true })).toMatchObject({ success: true }); + expect(schema.toJSONSchema({ target: "draft-07" })).toMatchObject({ type: "object" }); + } + }); + + it("retains setup helpers used by published Slack and Discord packages", () => { + expect(createLegacyCompatChannelDmPolicy).toBeTypeOf("function"); + expect(promptLegacyChannelAllowFromForAccount).toBeTypeOf("function"); + }); +}); diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index 5a9afcb3e281..6578e4cf7cb8 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -636,6 +636,32 @@ const PLUGIN_COMPAT_RECORDS = [ releaseNote: "Legacy `runEmbeddedPiAgent` and `EmbeddedPi*` plugin aliases remain as deprecated SDK compatibility only.", }, + { + code: "plugin-sdk-shipped-channel-setup-exports", + status: "deprecated", + owner: "channel", + introduced: "2026-07-23", + deprecated: "2026-07-23", + warningStarts: "2026-07-23", + removeAfter: "2026-08-30", + replacement: + "plugin-owned config schemas plus generic `openclaw/plugin-sdk/channel-config-schema` and `openclaw/plugin-sdk/setup-runtime` primitives", + docsPath: "/plugins/sdk-migration#published-channel-setup-compatibility", + surfaces: [ + "openclaw/plugin-sdk/bundled-channel-config-schema SlackConfigSchema", + "openclaw/plugin-sdk/bundled-channel-config-schema DiscordConfigSchema", + "openclaw/plugin-sdk/bundled-channel-config-schema SignalConfigSchema", + "openclaw/plugin-sdk/bundled-channel-config-schema MSTeamsConfigSchema", + "openclaw/plugin-sdk/setup-runtime createLegacyCompatChannelDmPolicy", + "openclaw/plugin-sdk/setup-runtime promptLegacyChannelAllowFromForAccount", + ], + diagnostics: [ + "repository deprecated API usage guard for core and bundled plugins; no external runtime import warning", + ], + tests: ["src/plugin-sdk/shipped-channel-compat.test.ts", "src/plugins/compat/registry.test.ts"], + releaseNote: + "Published OpenClaw channel packages through 2026.7.1 remain loadable while they migrate to plugin-owned config and setup helpers.", + }, { code: "generated-bundled-channel-config-fallback", status: "active", diff --git a/src/plugins/contracts/config-footprint-guardrails.test.ts b/src/plugins/contracts/config-footprint-guardrails.test.ts index 723907eb03b1..e0f6ecc0c534 100644 --- a/src/plugins/contracts/config-footprint-guardrails.test.ts +++ b/src/plugins/contracts/config-footprint-guardrails.test.ts @@ -188,7 +188,7 @@ describe("config footprint guardrails", () => { ); }); - it("keeps bundled channel schemas out of the generic channel config SDK surface", () => { + it("keeps current channel schemas plugin-owned with a narrow shipped compatibility tier", () => { const source = readSource("src/plugin-sdk/channel-config-schema.ts"); const bundledSource = readSource("src/plugin-sdk/bundled-channel-config-schema.ts"); const bundledSection = bundledSource.slice( @@ -215,8 +215,12 @@ describe("config footprint guardrails", () => { ].toSorted((left, right) => left.localeCompare(right)); expect(exportedSchemaNames).toEqual([ + "DiscordConfigSchema", "GoogleChatConfigSchema", "IMessageConfigSchema", + "MSTeamsConfigSchema", + "SignalConfigSchema", + "SlackConfigSchema", "TelegramConfigSchema", "WhatsAppConfigSchema", ]); @@ -245,8 +249,9 @@ describe("config footprint guardrails", () => { // channel-config-schema is the canonical internal module; the primitives // and bundled shells stay export-compatible for plugins only. const allowedShellImporters = new Set([ - // The facade's focused regression test is its only internal consumer. + // The facade's focused regression tests are its only internal consumers. "src/plugin-sdk/bundled-channel-config-schema.test.ts", + "src/plugin-sdk/shipped-channel-compat.test.ts", // This guardrail file embeds facade specifiers in shell-shape assertions. "src/plugins/contracts/config-footprint-guardrails.test.ts", ]); diff --git a/src/plugins/plugin-lifecycle-lease.test.ts b/src/plugins/plugin-lifecycle-lease.test.ts index 98931c5783c6..c0292492fdd6 100644 --- a/src/plugins/plugin-lifecycle-lease.test.ts +++ b/src/plugins/plugin-lifecycle-lease.test.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; +import { readPersistedInstalledPluginIndex } from "./installed-plugin-index-store.js"; import { withPluginLifecycleLease } from "./plugin-lifecycle-lease.js"; afterEach(() => { @@ -86,6 +87,47 @@ describe("plugin lifecycle lease", () => { }); }); + it("uses an explicit shared database path instead of each caller's default state", async () => { + await withOpenClawTestState({ label: "plugin-lifecycle-explicit-path" }, async (state) => { + const databasePath = state.path("shared-plugin-lifecycle.sqlite"); + const firstEntered = deferred(); + const releaseFirst = deferred(); + const events: string[] = []; + const first = withPluginLifecycleLease( + { + env: { ...state.env, OPENCLAW_STATE_DIR: state.path("state-a") }, + path: databasePath, + leaseMs: 1_000, + waitMs: 3_000, + }, + async () => { + events.push("first-enter"); + firstEntered.resolve(); + await releaseFirst.promise; + }, + ); + await firstEntered.promise; + const second = withPluginLifecycleLease( + { + env: { ...state.env, OPENCLAW_STATE_DIR: state.path("state-b") }, + path: databasePath, + leaseMs: 1_000, + waitMs: 3_000, + }, + async () => { + events.push("second-enter"); + }, + ); + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + expect(events).toEqual(["first-enter"]); + releaseFirst.resolve(); + await Promise.all([first, second]); + expect(events).toEqual(["first-enter", "second-enter"]); + }); + }); + it("serializes lifecycle work across processes", async () => { await withOpenClawTestState({ label: "plugin-lifecycle-processes" }, async (state) => { const firstMarker = state.path("first-entered"); @@ -167,6 +209,66 @@ describe("plugin lifecycle lease", () => { }); }); + it("reloads install records after waiting for another process", async () => { + await withOpenClawTestState({ label: "plugin-lifecycle-record-cache" }, async (state) => { + const leaseModuleUrl = pathToFileURL( + path.resolve("src/plugins/plugin-lifecycle-lease.ts"), + ).href; + const recordsModuleUrl = pathToFileURL( + path.resolve("src/plugins/installed-plugin-index-records.ts"), + ).href; + const goMarker = state.path("go"); + const readyAlpha = state.path("ready-alpha"); + const readyBeta = state.path("ready-beta"); + const childScript = await state.writeText( + "record-cache-child.mts", + ` + import fs from "node:fs/promises"; + import { withPluginLifecycleLease } from ${JSON.stringify(leaseModuleUrl)}; + import { + loadInstalledPluginIndexInstallRecords, + writePersistedInstalledPluginIndexInstallRecords, + } from ${JSON.stringify(recordsModuleUrl)}; + const [pluginId, stateDir, readyMarker, goMarker] = process.argv.slice(2); + process.env.OPENCLAW_STATE_DIR = stateDir; + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + await loadInstalledPluginIndexInstallRecords(); + await fs.writeFile(readyMarker, "ready"); + while (true) { + try { + await fs.access(goMarker); + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + await withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 5_000 }, async () => { + const records = await loadInstalledPluginIndexInstallRecords(); + await writePersistedInstalledPluginIndexInstallRecords({ + ...records, + [pluginId]: { + source: "path", + spec: pluginId, + sourcePath: "/tmp/" + pluginId, + installPath: "/tmp/" + pluginId, + }, + }); + }); + `, + ); + + const alpha = runLeaseChild(childScript, ["alpha", state.stateDir, readyAlpha, goMarker]); + const beta = runLeaseChild(childScript, ["beta", state.stateDir, readyBeta, goMarker]); + await Promise.all([waitForPath(readyAlpha), waitForPath(readyBeta)]); + await fs.writeFile(goMarker, "go"); + await Promise.all([alpha, beta]); + + closeOpenClawStateDatabaseForTest(); + const persisted = await readPersistedInstalledPluginIndex({ env: state.env }); + expect(Object.keys(persisted?.installRecords ?? {}).toSorted()).toEqual(["alpha", "beta"]); + }); + }); + it("reuses the active lease for nested lifecycle work", async () => { await withOpenClawTestState({ label: "plugin-lifecycle-reentrant" }, async (state) => { const events: string[] = []; diff --git a/src/plugins/plugin-lifecycle-lease.ts b/src/plugins/plugin-lifecycle-lease.ts index 4ac3afabc1f0..ae6a4a630199 100644 --- a/src/plugins/plugin-lifecycle-lease.ts +++ b/src/plugins/plugin-lifecycle-lease.ts @@ -1,11 +1,13 @@ import { AsyncLocalStorage } from "node:async_hooks"; import path from "node:path"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { OpenClawStateLeaseError, withOpenClawStateLease, type OpenClawStateLeaseContext, } from "../state/openclaw-state-lease.js"; +import { clearLoadInstalledPluginIndexInstallRecordsCache } from "./installed-plugin-index-record-cache.js"; const PLUGIN_LIFECYCLE_LEASE_SCOPE = "core:plugin-lifecycle"; const PLUGIN_LIFECYCLE_LEASE_KEY = "global"; @@ -17,8 +19,10 @@ type ActivePluginLifecycleLease = { lease: OpenClawStateLeaseContext; }; -type PluginLifecycleLeaseOptions = { - env?: NodeJS.ProcessEnv; +type PluginLifecycleLeaseOptions = Pick< + OpenClawStateDatabaseOptions, + "env" | "path" | "database" +> & { signal?: AbortSignal; leaseMs?: number; waitMs?: number; @@ -45,7 +49,9 @@ export async function withPluginLifecycleLease( run: (lease: OpenClawStateLeaseContext) => Promise, ): Promise { const env = resolveLifecycleLeaseEnv(options.env); - const databasePath = path.resolve(resolveOpenClawStateSqlitePath(env)); + const databasePath = path.resolve( + options.database?.path ?? options.path ?? resolveOpenClawStateSqlitePath(env), + ); const active = activePluginLifecycleLease.getStore(); if (active) { if (active.databasePath !== databasePath) { @@ -63,14 +69,27 @@ export async function withPluginLifecycleLease( { scope: PLUGIN_LIFECYCLE_LEASE_SCOPE, key: PLUGIN_LIFECYCLE_LEASE_KEY, - database: { scope: "shared", options: { env } }, + database: { + scope: "shared", + options: { + env, + ...(options.path ? { path: options.path } : {}), + ...(options.database ? { database: options.database } : {}), + }, + }, leaseMs: options.leaseMs ?? DEFAULT_PLUGIN_LIFECYCLE_LEASE_MS, waitMs: options.waitMs ?? DEFAULT_PLUGIN_LIFECYCLE_WAIT_MS, ...(options.signal ? { signal: options.signal } : {}), leaseLabel: "plugin lifecycle lease", operationLabel: "plugins.lifecycle.lease", }, - async (lease) => - await activePluginLifecycleLease.run({ databasePath, lease }, async () => await run(lease)), + async (lease) => { + // Another process may have committed while this process waited for ownership. + clearLoadInstalledPluginIndexInstallRecordsCache(); + return await activePluginLifecycleLease.run( + { databasePath, lease }, + async () => await run(lease), + ); + }, ); }