mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 16:10:20 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -278,7 +278,9 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
allowPrivateNetwork: true,
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
103
extensions/bluebubbles/src/doctor-contract.ts
Normal file
103
extensions/bluebubbles/src/doctor-contract.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
34
extensions/bluebubbles/src/doctor.test.ts
Normal file
34
extensions/bluebubbles/src/doctor.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
105
extensions/bluebubbles/src/doctor.ts
Normal file
105
extensions/bluebubbles/src/doctor.ts
Normal 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),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user