diff --git a/extensions/imessage/doctor-contract-api.ts b/extensions/imessage/doctor-contract-api.ts new file mode 100644 index 00000000000..c308f031192 --- /dev/null +++ b/extensions/imessage/doctor-contract-api.ts @@ -0,0 +1,5 @@ +import type { ChannelDoctorLegacyConfigRule } from "openclaw/plugin-sdk/channel-contract"; + +// iMessage does not expose doctor legacy rules today. Keep that empty answer on +// a lightweight contract surface so doctor scans stay off the full plugin path. +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = []; diff --git a/extensions/zalouser/contract-api.ts b/extensions/zalouser/contract-api.ts index bf5f4d0f298..4dc839515b6 100644 --- a/extensions/zalouser/contract-api.ts +++ b/extensions/zalouser/contract-api.ts @@ -1,2 +1,2 @@ export { collectZalouserSecurityAuditFindings } from "./src/security-audit.js"; -export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor.js"; +export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js"; diff --git a/extensions/zalouser/doctor-contract-api.ts b/extensions/zalouser/doctor-contract-api.ts new file mode 100644 index 00000000000..a7a56f23442 --- /dev/null +++ b/extensions/zalouser/doctor-contract-api.ts @@ -0,0 +1 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; diff --git a/extensions/zalouser/src/doctor-contract.ts b/extensions/zalouser/src/doctor-contract.ts new file mode 100644 index 00000000000..1300a212d91 --- /dev/null +++ b/extensions/zalouser/src/doctor-contract.ts @@ -0,0 +1,156 @@ +import type { + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; + +type ZalouserChannelsConfig = NonNullable; + +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function hasLegacyZalouserGroupAllowAlias(value: unknown): boolean { + const group = asObjectRecord(value); + return Boolean(group && typeof group.allow === "boolean"); +} + +function hasLegacyZalouserGroupAllowAliases(value: unknown): boolean { + const groups = asObjectRecord(value); + return Boolean( + groups && Object.values(groups).some((group) => hasLegacyZalouserGroupAllowAlias(group)), + ); +} + +function hasLegacyZalouserAccountGroupAllowAliases(value: unknown): boolean { + const accounts = asObjectRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => { + const accountRecord = asObjectRecord(account); + return Boolean(accountRecord && hasLegacyZalouserGroupAllowAliases(accountRecord.groups)); + }); +} + +function normalizeZalouserGroupAllowAliases(params: { + groups: Record; + pathPrefix: string; + changes: string[]; +}): { groups: Record; changed: boolean } { + let changed = false; + const nextGroups: Record = { ...params.groups }; + for (const [groupId, groupValue] of Object.entries(params.groups)) { + const group = asObjectRecord(groupValue); + if (!group || typeof group.allow !== "boolean") { + continue; + } + const nextGroup = { ...group }; + if (typeof nextGroup.enabled !== "boolean") { + nextGroup.enabled = group.allow; + } + delete nextGroup.allow; + nextGroups[groupId] = nextGroup; + changed = true; + params.changes.push( + `Moved ${params.pathPrefix}.${groupId}.allow → ${params.pathPrefix}.${groupId}.enabled (${String(nextGroup.enabled)}).`, + ); + } + return { groups: nextGroups, changed }; +} + +function normalizeZalouserCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { + const channels = asObjectRecord(cfg.channels); + const zalouser = asObjectRecord(channels?.zalouser); + if (!zalouser) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updatedZalouser: Record = zalouser; + let changed = false; + + const groups = asObjectRecord(updatedZalouser.groups); + if (groups) { + const normalized = normalizeZalouserGroupAllowAliases({ + groups, + pathPrefix: "channels.zalouser.groups", + changes, + }); + if (normalized.changed) { + updatedZalouser = { ...updatedZalouser, groups: normalized.groups }; + changed = true; + } + } + + const accounts = asObjectRecord(updatedZalouser.accounts); + if (accounts) { + let accountsChanged = false; + const nextAccounts: Record = { ...accounts }; + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = asObjectRecord(accountValue); + if (!account) { + continue; + } + const accountGroups = asObjectRecord(account.groups); + if (!accountGroups) { + continue; + } + const normalized = normalizeZalouserGroupAllowAliases({ + groups: accountGroups, + pathPrefix: `channels.zalouser.accounts.${accountId}.groups`, + changes, + }); + if (!normalized.changed) { + continue; + } + nextAccounts[accountId] = { + ...account, + groups: normalized.groups, + }; + accountsChanged = true; + } + if (accountsChanged) { + updatedZalouser = { ...updatedZalouser, accounts: nextAccounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + zalouser: updatedZalouser as ZalouserChannelsConfig["zalouser"], + }, + }, + changes, + }; +} + +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "zalouser", "groups"], + message: + 'channels.zalouser.groups..allow is legacy; use channels.zalouser.groups..enabled instead. Run "openclaw doctor --fix".', + match: hasLegacyZalouserGroupAllowAliases, + }, + { + path: ["channels", "zalouser", "accounts"], + message: + 'channels.zalouser.accounts..groups..allow is legacy; use channels.zalouser.accounts..groups..enabled instead. Run "openclaw doctor --fix".', + match: hasLegacyZalouserAccountGroupAllowAliases, + }, +]; + +export function normalizeCompatibilityConfig(params: { + cfg: OpenClawConfig; +}): ChannelDoctorConfigMutation { + return normalizeZalouserCompatibilityConfig(params.cfg); +} diff --git a/extensions/zalouser/src/doctor.ts b/extensions/zalouser/src/doctor.ts index b13a1f41ea9..373d435ee2d 100644 --- a/extensions/zalouser/src/doctor.ts +++ b/extensions/zalouser/src/doctor.ts @@ -1,165 +1,14 @@ -import type { - ChannelDoctorAdapter, - ChannelDoctorConfigMutation, - ChannelDoctorLegacyConfigRule, -} from "openclaw/plugin-sdk/channel-contract"; +import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js"; import { isZalouserMutableGroupEntry } from "./security-audit.js"; -type ZalouserChannelsConfig = NonNullable; - function asObjectRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } -function hasLegacyZalouserGroupAllowAlias(value: unknown): boolean { - const group = asObjectRecord(value); - return Boolean(group && typeof group.allow === "boolean"); -} - -function hasLegacyZalouserGroupAllowAliases(value: unknown): boolean { - const groups = asObjectRecord(value); - return Boolean( - groups && Object.values(groups).some((group) => hasLegacyZalouserGroupAllowAlias(group)), - ); -} - -function hasLegacyZalouserAccountGroupAllowAliases(value: unknown): boolean { - const accounts = asObjectRecord(value); - if (!accounts) { - return false; - } - return Object.values(accounts).some((account) => { - const accountRecord = asObjectRecord(account); - return Boolean(accountRecord && hasLegacyZalouserGroupAllowAliases(accountRecord.groups)); - }); -} - -function normalizeZalouserGroupAllowAliases(params: { - groups: Record; - pathPrefix: string; - changes: string[]; -}): { groups: Record; changed: boolean } { - let changed = false; - const nextGroups: Record = { ...params.groups }; - for (const [groupId, groupValue] of Object.entries(params.groups)) { - const group = asObjectRecord(groupValue); - if (!group || typeof group.allow !== "boolean") { - continue; - } - const nextGroup = { ...group }; - if (typeof nextGroup.enabled !== "boolean") { - nextGroup.enabled = group.allow; - } - delete nextGroup.allow; - nextGroups[groupId] = nextGroup; - changed = true; - params.changes.push( - `Moved ${params.pathPrefix}.${groupId}.allow → ${params.pathPrefix}.${groupId}.enabled (${String(nextGroup.enabled)}).`, - ); - } - return { groups: nextGroups, changed }; -} - -function normalizeZalouserCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { - const channels = asObjectRecord(cfg.channels); - const zalouser = asObjectRecord(channels?.zalouser); - if (!zalouser) { - return { config: cfg, changes: [] }; - } - - const changes: string[] = []; - let updatedZalouser: Record = zalouser; - let changed = false; - - const groups = asObjectRecord(updatedZalouser.groups); - if (groups) { - const normalized = normalizeZalouserGroupAllowAliases({ - groups, - pathPrefix: "channels.zalouser.groups", - changes, - }); - if (normalized.changed) { - updatedZalouser = { ...updatedZalouser, groups: normalized.groups }; - changed = true; - } - } - - const accounts = asObjectRecord(updatedZalouser.accounts); - if (accounts) { - let accountsChanged = false; - const nextAccounts: Record = { ...accounts }; - for (const [accountId, accountValue] of Object.entries(accounts)) { - const account = asObjectRecord(accountValue); - if (!account) { - continue; - } - const accountGroups = asObjectRecord(account.groups); - if (!accountGroups) { - continue; - } - const normalized = normalizeZalouserGroupAllowAliases({ - groups: accountGroups, - pathPrefix: `channels.zalouser.accounts.${accountId}.groups`, - changes, - }); - if (!normalized.changed) { - continue; - } - nextAccounts[accountId] = { - ...account, - groups: normalized.groups, - }; - accountsChanged = true; - } - if (accountsChanged) { - updatedZalouser = { ...updatedZalouser, accounts: nextAccounts }; - changed = true; - } - } - - if (!changed) { - return { config: cfg, changes: [] }; - } - - return { - config: { - ...cfg, - channels: { - ...cfg.channels, - zalouser: updatedZalouser as ZalouserChannelsConfig["zalouser"], - }, - }, - changes, - }; -} - -const ZALOUSER_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ - { - path: ["channels", "zalouser", "groups"], - message: - 'channels.zalouser.groups..allow is legacy; use channels.zalouser.groups..enabled instead. Run "openclaw doctor --fix".', - match: hasLegacyZalouserGroupAllowAliases, - }, - { - path: ["channels", "zalouser", "accounts"], - message: - 'channels.zalouser.accounts..groups..allow is legacy; use channels.zalouser.accounts..groups..enabled instead. Run "openclaw doctor --fix".', - match: hasLegacyZalouserAccountGroupAllowAliases, - }, -]; - -export const legacyConfigRules = ZALOUSER_LEGACY_CONFIG_RULES; - -export function normalizeCompatibilityConfig(params: { - cfg: OpenClawConfig; -}): ChannelDoctorConfigMutation { - return normalizeZalouserCompatibilityConfig(params.cfg); -} - export const collectZalouserMutableAllowlistWarnings = createDangerousNameMatchingMutableAllowlistWarningCollector({ channel: "zalouser", diff --git a/src/channels/plugins/legacy-config.test.ts b/src/channels/plugins/legacy-config.test.ts index 63024fca5d4..39e897d2e00 100644 --- a/src/channels/plugins/legacy-config.test.ts +++ b/src/channels/plugins/legacy-config.test.ts @@ -107,6 +107,55 @@ describe("collectChannelLegacyConfigRules", () => { }); }); + it("does not rescan registry when a bundled bootstrap plugin has no legacy rules", () => { + getBootstrapChannelPluginMock.mockImplementation((channelId: string) => + channelId === "imessage" + ? { + doctor: {}, + } + : undefined, + ); + + const rules = collectChannelLegacyConfigRules({ + channels: { + imessage: {}, + }, + }); + + expect(rules).toEqual([]); + expect(listPluginDoctorLegacyConfigRulesMock).not.toHaveBeenCalled(); + }); + + it("treats empty doctor-contract legacy rules as authoritative", () => { + loadBundledChannelDoctorContractApiMock.mockImplementation((channelId: string) => + channelId === "imessage" ? { legacyConfigRules: [] } : undefined, + ); + getBootstrapChannelPluginMock.mockImplementation((channelId: string) => + channelId === "imessage" + ? { + doctor: { + legacyConfigRules: [ + { + path: ["channels", "imessage", "legacy"], + message: "should not load bootstrap rules", + }, + ], + }, + } + : undefined, + ); + + const rules = collectChannelLegacyConfigRules({ + channels: { + imessage: {}, + }, + }); + + expect(rules).toEqual([]); + expect(getBootstrapChannelPluginMock).not.toHaveBeenCalled(); + expect(listPluginDoctorLegacyConfigRulesMock).not.toHaveBeenCalled(); + }); + it("scopes channel legacy scans to touched channels during dry-run validation", () => { loadBundledChannelDoctorContractApiMock.mockImplementation((channelId: string) => ({ legacyConfigRules: [ diff --git a/src/channels/plugins/legacy-config.ts b/src/channels/plugins/legacy-config.ts index 3ac6c966590..d701c1ab3a6 100644 --- a/src/channels/plugins/legacy-config.ts +++ b/src/channels/plugins/legacy-config.ts @@ -82,8 +82,9 @@ export function collectChannelLegacyConfigRules( const rules: LegacyConfigRule[] = []; const unresolvedChannelIds: ChannelId[] = []; for (const channelId of channelIds) { - const contractRules = loadBundledChannelDoctorContractApi(channelId)?.legacyConfigRules; - if (Array.isArray(contractRules) && contractRules.length > 0) { + const contractApi = loadBundledChannelDoctorContractApi(channelId); + const contractRules = contractApi?.legacyConfigRules; + if (Array.isArray(contractRules)) { rules.push(...contractRules); continue; } @@ -93,6 +94,9 @@ export function collectChannelLegacyConfigRules( rules.push(...plugin.doctor.legacyConfigRules); continue; } + if (plugin) { + continue; + } unresolvedChannelIds.push(channelId); }