refactor: dedupe legacy private-network doctor contracts

This commit is contained in:
Peter Steinberger
2026-04-06 17:27:58 +01:00
parent ce30557399
commit e7e3f11b20
6 changed files with 121 additions and 402 deletions

View File

@@ -1,103 +1,9 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
hasLegacyFlatAllowPrivateNetworkAlias,
migrateLegacyFlatAllowPrivateNetworkAlias,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
const contract = createLegacyPrivateNetworkDoctorContract({
channelKey: "bluebubbles",
});
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
export const legacyConfigRules = contract.legacyConfigRules;
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "bluebubbles"],
message:
'channels.bluebubbles.allowPrivateNetwork is legacy; use channels.bluebubbles.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "bluebubbles", "accounts"],
message:
'channels.bluebubbles.accounts.<id>.allowPrivateNetwork is legacy; use channels.bluebubbles.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const bluebubbles = isRecord(channels?.bluebubbles) ? channels.bluebubbles : null;
if (!bluebubbles) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedBluebubbles = bluebubbles;
let changed = false;
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedBluebubbles,
pathPrefix: "channels.bluebubbles",
changes,
});
updatedBluebubbles = topLevel.entry;
changed = changed || topLevel.changed;
const accounts = isRecord(updatedBluebubbles.accounts) ? updatedBluebubbles.accounts : null;
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = isRecord(accountValue) ? accountValue : null;
if (!account) {
continue;
}
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: account,
pathPrefix: `channels.bluebubbles.accounts.${accountId}`,
changes,
});
if (!migrated.changed) {
continue;
}
nextAccounts[accountId] = migrated.entry;
accountsChanged = true;
}
if (accountsChanged) {
updatedBluebubbles = { ...updatedBluebubbles, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
bluebubbles: updatedBluebubbles as NonNullable<OpenClawConfig["channels"]>["bluebubbles"],
},
},
changes,
};
}
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;

View File

@@ -1,103 +1,9 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
hasLegacyFlatAllowPrivateNetworkAlias,
migrateLegacyFlatAllowPrivateNetworkAlias,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
const contract = createLegacyPrivateNetworkDoctorContract({
channelKey: "mattermost",
});
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
export const legacyConfigRules = contract.legacyConfigRules;
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "mattermost"],
message:
'channels.mattermost.allowPrivateNetwork is legacy; use channels.mattermost.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "mattermost", "accounts"],
message:
'channels.mattermost.accounts.<id>.allowPrivateNetwork is legacy; use channels.mattermost.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const mattermost = isRecord(channels?.mattermost) ? channels.mattermost : null;
if (!mattermost) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedMattermost = mattermost;
let changed = false;
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedMattermost,
pathPrefix: "channels.mattermost",
changes,
});
updatedMattermost = topLevel.entry;
changed = changed || topLevel.changed;
const accounts = isRecord(updatedMattermost.accounts) ? updatedMattermost.accounts : null;
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = isRecord(accountValue) ? accountValue : null;
if (!account) {
continue;
}
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: account,
pathPrefix: `channels.mattermost.accounts.${accountId}`,
changes,
});
if (!migrated.changed) {
continue;
}
nextAccounts[accountId] = migrated.entry;
accountsChanged = true;
}
if (accountsChanged) {
updatedMattermost = { ...updatedMattermost, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
mattermost: updatedMattermost as NonNullable<OpenClawConfig["channels"]>["mattermost"],
},
},
changes,
};
}
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;

View File

@@ -1,105 +1,9 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
hasLegacyFlatAllowPrivateNetworkAlias,
migrateLegacyFlatAllowPrivateNetworkAlias,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
const contract = createLegacyPrivateNetworkDoctorContract({
channelKey: "nextcloud-talk",
});
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
export const legacyConfigRules = contract.legacyConfigRules;
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "nextcloud-talk"],
message:
'channels.nextcloud-talk.allowPrivateNetwork is legacy; use channels.nextcloud-talk.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "nextcloud-talk", "accounts"],
message:
'channels.nextcloud-talk.accounts.<id>.allowPrivateNetwork is legacy; use channels.nextcloud-talk.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const nextcloudTalk = isRecord(channels?.["nextcloud-talk"]) ? channels["nextcloud-talk"] : null;
if (!nextcloudTalk) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedNextcloudTalk = nextcloudTalk;
let changed = false;
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedNextcloudTalk,
pathPrefix: "channels.nextcloud-talk",
changes,
});
updatedNextcloudTalk = topLevel.entry;
changed = changed || topLevel.changed;
const accounts = isRecord(updatedNextcloudTalk.accounts) ? updatedNextcloudTalk.accounts : null;
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = isRecord(accountValue) ? accountValue : null;
if (!account) {
continue;
}
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: account,
pathPrefix: `channels.nextcloud-talk.accounts.${accountId}`,
changes,
});
if (!migrated.changed) {
continue;
}
nextAccounts[accountId] = migrated.entry;
accountsChanged = true;
}
if (accountsChanged) {
updatedNextcloudTalk = { ...updatedNextcloudTalk, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": updatedNextcloudTalk as NonNullable<
OpenClawConfig["channels"]
>["nextcloud-talk"],
},
},
changes,
};
}
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;

View File

@@ -1,103 +1,9 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
hasLegacyFlatAllowPrivateNetworkAlias,
migrateLegacyFlatAllowPrivateNetworkAlias,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
const contract = createLegacyPrivateNetworkDoctorContract({
channelKey: "tlon",
});
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
export const legacyConfigRules = contract.legacyConfigRules;
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "tlon"],
message:
'channels.tlon.allowPrivateNetwork is legacy; use channels.tlon.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "tlon", "accounts"],
message:
'channels.tlon.accounts.<id>.allowPrivateNetwork is legacy; use channels.tlon.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const tlon = isRecord(channels?.tlon) ? channels.tlon : null;
if (!tlon) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedTlon = tlon;
let changed = false;
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedTlon,
pathPrefix: "channels.tlon",
changes,
});
updatedTlon = topLevel.entry;
changed = changed || topLevel.changed;
const accounts = isRecord(updatedTlon.accounts) ? updatedTlon.accounts : null;
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = isRecord(accountValue) ? accountValue : null;
if (!account) {
continue;
}
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: account,
pathPrefix: `channels.tlon.accounts.${accountId}`,
changes,
});
if (!migrated.changed) {
continue;
}
nextAccounts[accountId] = migrated.entry;
accountsChanged = true;
}
if (accountsChanged) {
updatedTlon = { ...updatedTlon, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
tlon: updatedTlon as NonNullable<OpenClawConfig["channels"]>["tlon"],
},
},
changes,
};
}
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;

View File

@@ -5,6 +5,11 @@ import {
type LookupFn,
type SsrFPolicy,
} from "../infra/net/ssrf.js";
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "./channel-contract.js";
import type { OpenClawConfig } from "./config-runtime.js";
export { isPrivateIpAddress };
export type { SsrFPolicy };
@@ -107,6 +112,97 @@ export function migrateLegacyFlatAllowPrivateNetworkAlias(params: {
return { entry: nextEntry, changed: true };
}
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = asRecord(value);
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(asRecord(account) ?? {}),
),
);
}
export function createLegacyPrivateNetworkDoctorContract(params: { channelKey: string }): {
legacyConfigRules: ChannelDoctorLegacyConfigRule[];
normalizeCompatibilityConfig: (params: { cfg: OpenClawConfig }) => ChannelDoctorConfigMutation;
} {
const pathPrefix = `channels.${params.channelKey}`;
return {
legacyConfigRules: [
{
path: ["channels", params.channelKey],
message: `${pathPrefix}.allowPrivateNetwork is legacy; use ${pathPrefix}.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".`,
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(asRecord(value) ?? {}),
},
{
path: ["channels", params.channelKey, "accounts"],
message: `${pathPrefix}.accounts.<id>.allowPrivateNetwork is legacy; use ${pathPrefix}.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".`,
match: hasLegacyAllowPrivateNetworkInAccounts,
},
],
normalizeCompatibilityConfig: ({ cfg }) => {
const channels = asRecord(cfg.channels);
const channelEntry = asRecord(channels?.[params.channelKey]);
if (!channelEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedChannel = channelEntry;
let changed = false;
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedChannel,
pathPrefix,
changes,
});
updatedChannel = topLevel.entry;
changed = changed || topLevel.changed;
const accounts = asRecord(updatedChannel.accounts);
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = asRecord(accountValue);
if (!account) {
continue;
}
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: account,
pathPrefix: `${pathPrefix}.accounts.${accountId}`,
changes,
});
if (!migrated.changed) {
continue;
}
nextAccounts[accountId] = migrated.entry;
accountsChanged = true;
}
if (accountsChanged) {
updatedChannel = { ...updatedChannel, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
[params.channelKey]: updatedChannel,
} as OpenClawConfig["channels"],
},
changes,
};
},
};
}
export function ssrfPolicyFromAllowPrivateNetwork(
allowPrivateNetwork: boolean | null | undefined,
): SsrFPolicy | undefined {

View File

@@ -15,6 +15,7 @@ export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
export {
assertHttpUrlTargetsPrivateNetwork,
buildHostnameAllowlistPolicyFromSuffixAllowlist,
createLegacyPrivateNetworkDoctorContract,
hasLegacyFlatAllowPrivateNetworkAlias,
isPrivateNetworkOptInEnabled,
migrateLegacyFlatAllowPrivateNetworkAlias,