fix(config): migrate bundled private-network aliases (#60862)

* refactor(plugin-sdk): centralize private-network opt-in semantics

* fix(config): migrate bundled private-network aliases

* fix(config): add bundled private-network doctor adapters

* fix(config): expose bundled channel migration hooks

* fix(config): prefer canonical private-network key

* test(config): refresh rebased private-network outputs
This commit is contained in:
Vincent Koc
2026-04-05 08:49:44 +01:00
committed by GitHub
parent 87b8680ded
commit c863ee1b86
73 changed files with 1935 additions and 87 deletions

View File

@@ -1,4 +1,7 @@
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
import {
isBlockedHostnameOrIp,
isPrivateNetworkOptInEnabled,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveBlueBubblesAccount } from "./accounts.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { normalizeResolvedSecretInputString } from "./secret-input.js";
@@ -58,6 +61,6 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
baseUrl,
password,
accountId: account.accountId,
allowPrivateNetwork: account.config.allowPrivateNetwork === true || autoAllowPrivateNetwork,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config) || autoAllowPrivateNetwork,
};
}

View File

@@ -7,6 +7,7 @@ import {
readStringParam,
} from "openclaw/plugin-sdk/channel-actions";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { resolveBlueBubblesAccount } from "./accounts.js";
import {
@@ -173,7 +174,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
baseUrl,
password,
target,
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
});
if (!resolved) {
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);

View File

@@ -278,7 +278,9 @@ describe("downloadBlueBubblesAttachment", () => {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test",
allowPrivateNetwork: true,
network: {
dangerouslyAllowPrivateNetwork: true,
},
},
},
},

View File

@@ -17,6 +17,7 @@ import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
@@ -34,6 +35,7 @@ import {
} from "./channel-shared.js";
import type { BlueBubblesProbe } from "./channel.runtime.js";
import { createBlueBubblesConversationBindingManager } from "./conversation-bindings.js";
import { bluebubblesDoctor } from "./doctor.js";
import {
matchBlueBubblesAcpConversation,
normalizeBlueBubblesAcpConversationId,
@@ -100,6 +102,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot => describeBlueBubblesAccount(account),
},
doctor: bluebubblesDoctor,
conversationBindings: {
supportsCurrentConversationBinding: true,
createManager: ({ cfg, accountId }) =>
@@ -226,7 +229,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
baseUrl: account.baseUrl,
password: account.config.password ?? null,
timeoutMs,
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
}),
resolveAccountSnapshot: ({ account, runtime, probe }) => {
const running = runtime?.running ?? false;

View File

@@ -32,6 +32,14 @@ const bluebubblesGroupConfigSchema = z.object({
tools: ToolPolicySchema,
});
const bluebubblesNetworkSchema = z
.object({
/** Dangerous opt-in for same-host or trusted private/internal BlueBubbles deployments. */
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
})
.strict()
.optional();
const bluebubblesAccountSchema = z
.object({
name: z.string().optional(),
@@ -53,7 +61,7 @@ const bluebubblesAccountSchema = z
mediaMaxMb: z.number().int().positive().optional(),
mediaLocalRoots: z.array(z.string()).optional(),
sendReadReceipts: z.boolean().optional(),
allowPrivateNetwork: z.boolean().optional(),
network: bluebubblesNetworkSchema,
blockStreaming: z.boolean().optional(),
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
})

View File

@@ -0,0 +1,103 @@
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";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
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: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "bluebubbles"],
message:
"channels.bluebubbles.allowPrivateNetwork is legacy; use channels.bluebubbles.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
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 (auto-migrated on load).",
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,
};
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { bluebubblesDoctor } from "./doctor.js";
describe("bluebubbles doctor", () => {
it("normalizes legacy private-network aliases", () => {
const normalize = bluebubblesDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
bluebubbles: {
allowPrivateNetwork: true,
accounts: {
default: {
allowPrivateNetwork: false,
},
},
},
},
} as never,
});
expect(result.config.channels?.bluebubbles?.network).toEqual({
dangerouslyAllowPrivateNetwork: true,
});
expect(result.config.channels?.bluebubbles?.accounts?.default?.network).toEqual({
dangerouslyAllowPrivateNetwork: false,
});
});
});

View File

@@ -0,0 +1,105 @@
import type {
ChannelDoctorAdapter,
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";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
function normalizeBlueBubblesCompatibilityConfig(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,
};
}
const BLUEBUBBLES_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "bluebubbles"],
message:
"channels.bluebubbles.allowPrivateNetwork is legacy; use channels.bluebubbles.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
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 (auto-migrated on load).",
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];
export const bluebubblesDoctor: ChannelDoctorAdapter = {
legacyConfigRules: BLUEBUBBLES_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeBlueBubblesCompatibilityConfig(cfg),
};

View File

@@ -3,6 +3,7 @@ import {
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveBlueBubblesConversationRoute } from "./conversation-route.js";
@@ -934,7 +935,7 @@ export async function processMessage(
chatGuid: message.chatGuid,
chatId: message.chatId,
chatIdentifier: message.chatIdentifier,
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
});
if (fetchedParticipants?.length) {
message.participants = fetchedParticipants;
@@ -1147,7 +1148,7 @@ export async function processMessage(
baseUrl,
password,
target: resolveTarget,
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
})) ?? undefined;
}
}

View File

@@ -1,5 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
@@ -327,7 +328,7 @@ export async function monitorBlueBubblesProvider(
password: account.config.password,
accountId: account.accountId,
timeoutMs: 5000,
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
}).catch(() => null);
if (serverInfo?.os_version) {
runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);

View File

@@ -25,6 +25,11 @@ export type BlueBubblesActionConfig = {
sendAttachment?: boolean;
};
export type BlueBubblesNetworkConfig = {
/** Dangerous opt-in for same-host or trusted private/internal BlueBubbles deployments. */
dangerouslyAllowPrivateNetwork?: boolean;
};
export type BlueBubblesAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
@@ -71,8 +76,8 @@ export type BlueBubblesAccountConfig = {
mediaLocalRoots?: string[];
/** Send read receipts for incoming messages (default: true). */
sendReadReceipts?: boolean;
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */
allowPrivateNetwork?: boolean;
/** Network policy overrides for same-host or trusted private/internal BlueBubbles deployments. */
network?: BlueBubblesNetworkConfig;
/** Per-group configuration keyed by chat GUID or identifier. */
groups?: Record<string, BlueBubblesGroupConfig>;
/** Per-action tool gating (default: true for all). */