mirror of https://github.com/openclaw/openclaw.git
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:
parent
87b8680ded
commit
c863ee1b86
|
|
@ -1,4 +1,4 @@
|
|||
4c880eb1ce03486f47aa21f49317ad15fc8d92bb720d70205743b72e45cf5fa3 config-baseline.json
|
||||
03ff4a3e314f17dd8851aed3653269294bc62412bee05a6804dce840bd3d7551 config-baseline.core.json
|
||||
73b57f395a2ad983f1660112d0b2b998342f1ddbe3089b440d7f73d0665de739 config-baseline.channel.json
|
||||
20a882f9991e17310013471756ac7ec62c272e29490daeede9c0901bd51c0e69 config-baseline.json
|
||||
8ba6e5c959d5fc3eee9e6c5d1d8f764f164052f4207c0352bb39e2a7dbad64a8 config-baseline.core.json
|
||||
ca6d1fa8a3507566979ea2da2b88a6a7ae49d650f3ebd3eee14a22ed18e5be89 config-baseline.channel.json
|
||||
17fd37605bf6cb087932ec2ebcfa9dd22e669fa6b8b93081ab2deac9d24821c5 config-baseline.plugin.json
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export {
|
|||
resetMatrixThreadBindingsForTests,
|
||||
} from "./src/matrix/thread-bindings.js";
|
||||
export { setMatrixRuntime } from "./src/runtime.js";
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
export {
|
||||
namedAccountPromotionKeys,
|
||||
resolveSingleAccountPromotionTarget,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
export {
|
||||
namedAccountPromotionKeys,
|
||||
resolveSingleAccountPromotionTarget,
|
||||
|
|
|
|||
|
|
@ -254,7 +254,9 @@ describe("matrix setup post-write bootstrap", () => {
|
|||
channels: {
|
||||
matrix: {
|
||||
homeserver: "http://localhost.localdomain:8008",
|
||||
allowPrivateNetwork: true,
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
accounts: {
|
||||
ops: {
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ const matrixConfigAdapter = createScopedChannelConfigAdapter<
|
|||
clearBaseFields: [
|
||||
"name",
|
||||
"homeserver",
|
||||
"allowPrivateNetwork",
|
||||
"network",
|
||||
"proxy",
|
||||
"userId",
|
||||
"accessToken",
|
||||
|
|
|
|||
|
|
@ -55,6 +55,13 @@ const matrixRoomSchema = z
|
|||
})
|
||||
.optional();
|
||||
|
||||
const matrixNetworkSchema = z
|
||||
.object({
|
||||
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const MatrixConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
|
|
@ -62,7 +69,7 @@ export const MatrixConfigSchema = z.object({
|
|||
accounts: z.record(z.string(), z.unknown()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
homeserver: z.string().optional(),
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
network: matrixNetworkSchema,
|
||||
proxy: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
accessToken: buildSecretInputSchema().optional(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
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 hasLegacyMatrixRoomAllowAlias(value: unknown): boolean {
|
||||
const room = isRecord(value) ? value : null;
|
||||
return Boolean(room && typeof room.allow === "boolean");
|
||||
}
|
||||
|
||||
function hasLegacyMatrixRoomMapAllowAliases(value: unknown): boolean {
|
||||
const rooms = isRecord(value) ? value : null;
|
||||
return Boolean(rooms && Object.values(rooms).some((room) => hasLegacyMatrixRoomAllowAlias(room)));
|
||||
}
|
||||
|
||||
function hasLegacyMatrixAccountRoomAllowAliases(value: unknown): boolean {
|
||||
const accounts = isRecord(value) ? value : null;
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) => {
|
||||
if (!isRecord(account)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
hasLegacyMatrixRoomMapAllowAliases(account.groups) ||
|
||||
hasLegacyMatrixRoomMapAllowAliases(account.rooms)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function hasLegacyMatrixAccountPrivateNetworkAliases(value: unknown): boolean {
|
||||
const accounts = isRecord(value) ? value : null;
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) =>
|
||||
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMatrixRoomAllowAliases(params: {
|
||||
rooms: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { rooms: Record<string, unknown>; changed: boolean } {
|
||||
let changed = false;
|
||||
const nextRooms: Record<string, unknown> = { ...params.rooms };
|
||||
for (const [roomId, roomValue] of Object.entries(params.rooms)) {
|
||||
const room = isRecord(roomValue) ? roomValue : null;
|
||||
if (!room || typeof room.allow !== "boolean") {
|
||||
continue;
|
||||
}
|
||||
const nextRoom = { ...room };
|
||||
if (typeof nextRoom.enabled !== "boolean") {
|
||||
nextRoom.enabled = room.allow;
|
||||
}
|
||||
delete nextRoom.allow;
|
||||
nextRooms[roomId] = nextRoom;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.${roomId}.allow → ${params.pathPrefix}.${roomId}.enabled (${String(nextRoom.enabled)}).`,
|
||||
);
|
||||
}
|
||||
return { rooms: nextRooms, changed };
|
||||
}
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "matrix"],
|
||||
message:
|
||||
"channels.matrix.allowPrivateNetwork is legacy; use channels.matrix.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix", "accounts"],
|
||||
message:
|
||||
"channels.matrix.accounts.<id>.allowPrivateNetwork is legacy; use channels.matrix.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
match: hasLegacyMatrixAccountPrivateNetworkAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix", "groups"],
|
||||
message:
|
||||
"channels.matrix.groups.<room>.allow is legacy; use channels.matrix.groups.<room>.enabled instead (auto-migrated on load).",
|
||||
match: hasLegacyMatrixRoomMapAllowAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix", "rooms"],
|
||||
message:
|
||||
"channels.matrix.rooms.<room>.allow is legacy; use channels.matrix.rooms.<room>.enabled instead (auto-migrated on load).",
|
||||
match: hasLegacyMatrixRoomMapAllowAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix", "accounts"],
|
||||
message:
|
||||
"channels.matrix.accounts.<id>.{groups,rooms}.<room>.allow is legacy; use channels.matrix.accounts.<id>.{groups,rooms}.<room>.enabled instead (auto-migrated on load).",
|
||||
match: hasLegacyMatrixAccountRoomAllowAliases,
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
const channels = isRecord(cfg.channels) ? cfg.channels : null;
|
||||
const matrix = isRecord(channels?.matrix) ? channels.matrix : null;
|
||||
if (!matrix) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updatedMatrix: Record<string, unknown> = matrix;
|
||||
let changed = false;
|
||||
|
||||
const topLevelPrivateNetwork = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: updatedMatrix,
|
||||
pathPrefix: "channels.matrix",
|
||||
changes,
|
||||
});
|
||||
updatedMatrix = topLevelPrivateNetwork.entry;
|
||||
changed = changed || topLevelPrivateNetwork.changed;
|
||||
|
||||
const normalizeTopLevelRoomScope = (key: "groups" | "rooms") => {
|
||||
const rooms = isRecord(updatedMatrix[key]) ? updatedMatrix[key] : null;
|
||||
if (!rooms) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeMatrixRoomAllowAliases({
|
||||
rooms,
|
||||
pathPrefix: `channels.matrix.${key}`,
|
||||
changes,
|
||||
});
|
||||
if (normalized.changed) {
|
||||
updatedMatrix = { ...updatedMatrix, [key]: normalized.rooms };
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
|
||||
normalizeTopLevelRoomScope("groups");
|
||||
normalizeTopLevelRoomScope("rooms");
|
||||
|
||||
const accounts = isRecord(updatedMatrix.accounts) ? updatedMatrix.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;
|
||||
}
|
||||
let nextAccount: Record<string, unknown> = account;
|
||||
let accountChanged = false;
|
||||
|
||||
const privateNetworkMigration = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: nextAccount,
|
||||
pathPrefix: `channels.matrix.accounts.${accountId}`,
|
||||
changes,
|
||||
});
|
||||
if (privateNetworkMigration.changed) {
|
||||
nextAccount = privateNetworkMigration.entry;
|
||||
accountChanged = true;
|
||||
}
|
||||
|
||||
for (const key of ["groups", "rooms"] as const) {
|
||||
const rooms = isRecord(nextAccount[key]) ? nextAccount[key] : null;
|
||||
if (!rooms) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeMatrixRoomAllowAliases({
|
||||
rooms,
|
||||
pathPrefix: `channels.matrix.accounts.${accountId}.${key}`,
|
||||
changes,
|
||||
});
|
||||
if (normalized.changed) {
|
||||
nextAccount = { ...nextAccount, [key]: normalized.rooms };
|
||||
accountChanged = true;
|
||||
}
|
||||
}
|
||||
if (accountChanged) {
|
||||
nextAccounts[accountId] = nextAccount;
|
||||
accountsChanged = true;
|
||||
}
|
||||
}
|
||||
if (accountsChanged) {
|
||||
updatedMatrix = { ...updatedMatrix, accounts: nextAccounts };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg.channels ?? {}),
|
||||
matrix: updatedMatrix as NonNullable<OpenClawConfig["channels"]>["matrix"],
|
||||
},
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
|
@ -170,4 +170,40 @@ describe("matrix doctor", () => {
|
|||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes legacy Matrix private-network aliases", () => {
|
||||
const normalize = matrixDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
allowPrivateNetwork: true,
|
||||
accounts: {
|
||||
work: {
|
||||
allowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.matrix?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
});
|
||||
expect(result.config.channels?.matrix?.accounts?.work?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
});
|
||||
expect(result.changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Moved channels.matrix.allowPrivateNetwork → channels.matrix.network.dangerouslyAllowPrivateNetwork (true).",
|
||||
"Moved channels.matrix.accounts.work.allowPrivateNetwork → channels.matrix.accounts.work.network.dangerouslyAllowPrivateNetwork (false).",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import {
|
|||
formatPluginInstallPathIssue,
|
||||
removePluginFromConfig,
|
||||
} from "openclaw/plugin-sdk/runtime";
|
||||
import {
|
||||
hasLegacyFlatAllowPrivateNetworkAlias,
|
||||
isPrivateNetworkOptInEnabled,
|
||||
migrateLegacyFlatAllowPrivateNetworkAlias,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
autoMigrateLegacyMatrixState,
|
||||
autoPrepareLegacyMatrixCrypto,
|
||||
|
|
@ -49,6 +54,16 @@ function hasLegacyMatrixAccountRoomAllowAliases(value: unknown): boolean {
|
|||
});
|
||||
}
|
||||
|
||||
function hasLegacyMatrixAccountPrivateNetworkAliases(value: unknown): boolean {
|
||||
const accounts = isRecord(value) ? value : null;
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) =>
|
||||
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMatrixRoomAllowAliases(params: {
|
||||
rooms: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
|
|
@ -86,6 +101,14 @@ function normalizeMatrixCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorC
|
|||
let updatedMatrix: Record<string, unknown> = matrix;
|
||||
let changed = false;
|
||||
|
||||
const topLevelPrivateNetwork = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: updatedMatrix,
|
||||
pathPrefix: "channels.matrix",
|
||||
changes,
|
||||
});
|
||||
updatedMatrix = topLevelPrivateNetwork.entry;
|
||||
changed = changed || topLevelPrivateNetwork.changed;
|
||||
|
||||
const normalizeTopLevelRoomScope = (key: "groups" | "rooms") => {
|
||||
const rooms = isRecord(updatedMatrix[key]) ? updatedMatrix[key] : null;
|
||||
if (!rooms) {
|
||||
|
|
@ -116,6 +139,17 @@ function normalizeMatrixCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorC
|
|||
}
|
||||
let nextAccount: Record<string, unknown> = account;
|
||||
let accountChanged = false;
|
||||
|
||||
const privateNetworkMigration = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: nextAccount,
|
||||
pathPrefix: `channels.matrix.accounts.${accountId}`,
|
||||
changes,
|
||||
});
|
||||
if (privateNetworkMigration.changed) {
|
||||
nextAccount = privateNetworkMigration.entry;
|
||||
accountChanged = true;
|
||||
}
|
||||
|
||||
for (const key of ["groups", "rooms"] as const) {
|
||||
const rooms = isRecord(nextAccount[key]) ? nextAccount[key] : null;
|
||||
if (!rooms) {
|
||||
|
|
@ -158,6 +192,18 @@ function normalizeMatrixCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorC
|
|||
}
|
||||
|
||||
const MATRIX_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "matrix"],
|
||||
message:
|
||||
"channels.matrix.allowPrivateNetwork is legacy; use channels.matrix.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix", "accounts"],
|
||||
message:
|
||||
"channels.matrix.accounts.<id>.allowPrivateNetwork is legacy; use channels.matrix.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
match: hasLegacyMatrixAccountPrivateNetworkAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix", "groups"],
|
||||
message:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export {
|
|||
export { isPrivateOrLoopbackHost } from "./private-network-host.js";
|
||||
export {
|
||||
assertHttpUrlTargetsPrivateNetwork,
|
||||
isPrivateNetworkOptInEnabled,
|
||||
ssrfPolicyFromAllowPrivateNetwork,
|
||||
type LookupFn,
|
||||
type SsrFPolicy,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
DEFAULT_ACCOUNT_ID,
|
||||
assertHttpUrlTargetsPrivateNetwork,
|
||||
isPrivateOrLoopbackHost,
|
||||
isPrivateNetworkOptInEnabled,
|
||||
type LookupFn,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
|
|
@ -545,7 +546,7 @@ export function resolveMatrixConfig(
|
|||
});
|
||||
const initialSyncLimit = clampMatrixInitialSyncLimit(matrix.initialSyncLimit);
|
||||
const encryption = matrix.encryption ?? false;
|
||||
const allowPrivateNetwork = matrix.allowPrivateNetwork === true ? true : undefined;
|
||||
const allowPrivateNetwork = isPrivateNetworkOptInEnabled(matrix) ? true : undefined;
|
||||
return {
|
||||
homeserver: resolvedStrings.homeserver,
|
||||
userId: resolvedStrings.userId,
|
||||
|
|
@ -614,7 +615,9 @@ export function resolveMatrixConfigForAccount(
|
|||
const encryption =
|
||||
typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false);
|
||||
const allowPrivateNetwork =
|
||||
account.allowPrivateNetwork === true || matrix.allowPrivateNetwork === true ? true : undefined;
|
||||
isPrivateNetworkOptInEnabled(account) || isPrivateNetworkOptInEnabled(matrix)
|
||||
? true
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
homeserver: resolvedStrings.homeserver,
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ describe("updateMatrixAccountConfig", () => {
|
|||
accounts: {
|
||||
default: {
|
||||
allowBots: true,
|
||||
allowPrivateNetwork: true,
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
},
|
||||
|
|
@ -97,7 +99,7 @@ describe("updateMatrixAccountConfig", () => {
|
|||
expect(updated.channels?.["matrix"]?.accounts?.default).toMatchObject({
|
||||
allowBots: "mentions",
|
||||
});
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.allowPrivateNetwork).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.network).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.proxy).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -167,10 +167,19 @@ export function updateMatrixAccountConfig(
|
|||
applyNullableStringField(nextAccount, "avatarUrl", patch.avatarUrl);
|
||||
|
||||
if (patch.allowPrivateNetwork !== undefined) {
|
||||
const nextNetwork =
|
||||
nextAccount.network && typeof nextAccount.network === "object"
|
||||
? { ...(nextAccount.network as Record<string, unknown>) }
|
||||
: {};
|
||||
if (patch.allowPrivateNetwork === null) {
|
||||
delete nextAccount.allowPrivateNetwork;
|
||||
delete nextNetwork.dangerouslyAllowPrivateNetwork;
|
||||
} else {
|
||||
nextAccount.allowPrivateNetwork = patch.allowPrivateNetwork;
|
||||
nextNetwork.dangerouslyAllowPrivateNetwork = patch.allowPrivateNetwork;
|
||||
}
|
||||
if (Object.keys(nextNetwork).length > 0) {
|
||||
nextAccount.network = nextNetwork;
|
||||
} else {
|
||||
delete nextAccount.network;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco
|
|||
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import { ssrfPolicyFromAllowPrivateNetwork } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
isPrivateNetworkOptInEnabled,
|
||||
ssrfPolicyFromAllowPrivateNetwork,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveMatrixAccountStringValues } from "../auth-precedence.js";
|
||||
import { getMatrixScopedEnvVarNames } from "../env-vars.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
|
|
@ -273,8 +276,9 @@ export function resolveMatrixConfigForAccount(
|
|||
accountInitialSyncLimit ?? clampMatrixInitialSyncLimit(matrix.initialSyncLimit);
|
||||
const encryption =
|
||||
typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false);
|
||||
const allowPrivateNetwork =
|
||||
account.allowPrivateNetwork === true || matrix.allowPrivateNetwork === true ? true : undefined;
|
||||
const allowPrivateNetwork = isPrivateNetworkOptInEnabled(account) || isPrivateNetworkOptInEnabled(matrix)
|
||||
? true
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
homeserver: resolvedStrings.homeserver,
|
||||
|
|
|
|||
|
|
@ -187,7 +187,9 @@ describe("matrix onboarding", () => {
|
|||
|
||||
expect(result.cfg.channels?.matrix).toMatchObject({
|
||||
homeserver: "http://localhost.localdomain:8008",
|
||||
allowPrivateNetwork: true,
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
accessToken: "ops-token",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizardAdapter,
|
||||
|
|
@ -324,17 +325,15 @@ async function runMatrixConfigure(params: {
|
|||
).trim();
|
||||
const requiresAllowPrivateNetwork = requiresMatrixPrivateNetworkOptIn(homeserver);
|
||||
const shouldPromptAllowPrivateNetwork =
|
||||
requiresAllowPrivateNetwork || existing.allowPrivateNetwork === true;
|
||||
requiresAllowPrivateNetwork || isPrivateNetworkOptInEnabled(existing);
|
||||
const allowPrivateNetwork = shouldPromptAllowPrivateNetwork
|
||||
? await params.prompter.confirm({
|
||||
message: "Allow private/internal Matrix homeserver traffic for this account?",
|
||||
initialValue: existing.allowPrivateNetwork === true || requiresAllowPrivateNetwork,
|
||||
initialValue: isPrivateNetworkOptInEnabled(existing) || requiresAllowPrivateNetwork,
|
||||
})
|
||||
: false;
|
||||
if (requiresAllowPrivateNetwork && !allowPrivateNetwork) {
|
||||
throw new Error(
|
||||
"Matrix homeserver requires allowPrivateNetwork for trusted private/internal access",
|
||||
);
|
||||
throw new Error("Matrix homeserver requires explicit private-network opt-in");
|
||||
}
|
||||
await resolveValidatedMatrixHomeserverUrl(homeserver, {
|
||||
allowPrivateNetwork,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ export type MatrixExecApprovalConfig = {
|
|||
target?: MatrixExecApprovalTarget;
|
||||
};
|
||||
|
||||
export type MatrixNetworkConfig = {
|
||||
/** Dangerous opt-in for trusted private/internal Matrix homeservers. */
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
};
|
||||
|
||||
/** Per-account Matrix config (excludes the accounts field to prevent recursion). */
|
||||
export type MatrixAccountConfig = Omit<MatrixConfig, "accounts">;
|
||||
|
||||
|
|
@ -91,8 +96,8 @@ export type MatrixConfig = {
|
|||
defaultAccount?: string;
|
||||
/** Matrix homeserver URL (https://matrix.example.org). */
|
||||
homeserver?: string;
|
||||
/** Allow Matrix homeserver traffic to private/internal hosts. */
|
||||
allowPrivateNetwork?: boolean;
|
||||
/** Network policy overrides for trusted private/internal Matrix homeservers. */
|
||||
network?: MatrixNetworkConfig;
|
||||
/** Optional HTTP(S) proxy URL for Matrix connections (e.g. http://127.0.0.1:7890). */
|
||||
proxy?: string;
|
||||
/** Matrix user id (@user:server). */
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { mattermostApprovalAuth } from "./approval-auth.js";
|
||||
import {
|
||||
chunkTextForOutbound,
|
||||
|
|
@ -30,7 +31,7 @@ import {
|
|||
type ChannelPlugin,
|
||||
} from "./channel-api.js";
|
||||
import { MattermostChannelConfigSchema } from "./config-surface.js";
|
||||
import { collectMattermostMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { mattermostDoctor } from "./doctor.js";
|
||||
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
|
||||
import {
|
||||
listMattermostAccountIds,
|
||||
|
|
@ -330,9 +331,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
|||
}),
|
||||
},
|
||||
auth: mattermostApprovalAuth,
|
||||
doctor: {
|
||||
collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings,
|
||||
},
|
||||
doctor: mattermostDoctor,
|
||||
groups: {
|
||||
resolveRequireMention: resolveMattermostGroupRequireMention,
|
||||
},
|
||||
|
|
@ -388,7 +387,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
|||
baseUrl,
|
||||
token,
|
||||
timeoutMs,
|
||||
account.config.allowPrivateNetwork === true,
|
||||
isPrivateNetworkOptInEnabled(account.config),
|
||||
);
|
||||
},
|
||||
resolveAccountSnapshot: ({ account, runtime }) => ({
|
||||
|
|
|
|||
|
|
@ -70,6 +70,14 @@ const MattermostSlashCommandsSchema = z
|
|||
.strict()
|
||||
.optional();
|
||||
|
||||
const MattermostNetworkSchema = z
|
||||
.object({
|
||||
/** Dangerous opt-in for self-hosted Mattermost on trusted private/internal hosts. */
|
||||
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const MattermostAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
|
|
@ -107,8 +115,8 @@ const MattermostAccountSchemaBase = z
|
|||
.optional(),
|
||||
/** Per-group configuration (keyed by Mattermost channel ID or "*" for default). */
|
||||
groups: z.record(z.string(), MattermostGroupSchema.optional()).optional(),
|
||||
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
/** Network policy overrides for self-hosted Mattermost on trusted private/internal hosts. */
|
||||
network: MattermostNetworkSchema,
|
||||
/** Retry configuration for DM channel creation */
|
||||
dmChannelRetry: DmChannelRetrySchema,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -70,6 +70,14 @@ const MattermostSlashCommandsSchema = z
|
|||
.strict()
|
||||
.optional();
|
||||
|
||||
const MattermostNetworkSchema = z
|
||||
.object({
|
||||
/** Dangerous opt-in for self-hosted Mattermost on trusted private/internal hosts. */
|
||||
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const MattermostAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
|
|
@ -107,8 +115,8 @@ const MattermostAccountSchemaBase = z
|
|||
.optional(),
|
||||
/** Per-group configuration (keyed by Mattermost channel ID or "*" for default). */
|
||||
groups: z.record(z.string(), MattermostGroupSchema.optional()).optional(),
|
||||
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
/** Network policy overrides for self-hosted Mattermost on trusted private/internal hosts. */
|
||||
network: MattermostNetworkSchema,
|
||||
/** Retry configuration for DM channel creation */
|
||||
dmChannelRetry: DmChannelRetrySchema,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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", "mattermost"],
|
||||
message:
|
||||
"channels.mattermost.allowPrivateNetwork is legacy; use channels.mattermost.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
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 (auto-migrated on load).",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { mattermostDoctor } from "./doctor.js";
|
||||
|
||||
describe("mattermost doctor", () => {
|
||||
it("normalizes legacy private-network aliases", () => {
|
||||
const normalize = mattermostDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
allowPrivateNetwork: true,
|
||||
accounts: {
|
||||
work: {
|
||||
allowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.mattermost?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
});
|
||||
expect(result.config.channels?.mattermost?.accounts?.work?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,18 @@
|
|||
import type {
|
||||
ChannelDoctorAdapter,
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
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 isMattermostMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
|
|
@ -34,3 +48,95 @@ export const collectMattermostMutableAllowlistWarnings =
|
|||
},
|
||||
],
|
||||
});
|
||||
|
||||
function hasLegacyMattermostAllowPrivateNetworkInAccounts(value: unknown): boolean {
|
||||
const accounts = isRecord(value) ? value : null;
|
||||
return Boolean(
|
||||
accounts &&
|
||||
Object.values(accounts).some((account) =>
|
||||
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const MATTERMOST_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "mattermost"],
|
||||
message:
|
||||
"channels.mattermost.allowPrivateNetwork is legacy; use channels.mattermost.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
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 (auto-migrated on load).",
|
||||
match: hasLegacyMattermostAllowPrivateNetworkInAccounts,
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeMattermostCompatibilityConfig(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 mattermostDoctor: ChannelDoctorAdapter = {
|
||||
legacyConfigRules: MATTERMOST_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: ({ cfg }) => normalizeMattermostCompatibilityConfig(cfg),
|
||||
collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
|
||||
export type MattermostFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
|
@ -116,7 +119,7 @@ export function createMattermostClient(params: {
|
|||
url,
|
||||
init,
|
||||
auditContext: "mattermost-api",
|
||||
policy: params.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(params.allowPrivateNetwork),
|
||||
});
|
||||
try {
|
||||
const bodyBytes = NULL_BODY_STATUSES.has(response.status)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
|
|
@ -27,7 +28,7 @@ function buildClient(params: {
|
|||
return createMattermostClient({
|
||||
baseUrl: account.baseUrl,
|
||||
botToken: account.botToken,
|
||||
allowPrivateNetwork: account.config?.allowPrivateNetwork === true,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js";
|
||||
import {
|
||||
|
|
@ -273,7 +274,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||
const client = createMattermostClient({
|
||||
baseUrl,
|
||||
botToken,
|
||||
allowPrivateNetwork: account.config?.allowPrivateNetwork === true,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
||||
});
|
||||
|
||||
// Wait for the Mattermost API to accept our bot token before proceeding.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
|
||||
import type { BaseProbeResult } from "./runtime-api.js";
|
||||
|
||||
|
|
@ -33,7 +36,7 @@ export async function probeMattermost(
|
|||
signal: controller?.signal,
|
||||
},
|
||||
auditContext: "mattermost-probe",
|
||||
policy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(allowPrivateNetwork),
|
||||
});
|
||||
try {
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
|
|
@ -86,7 +87,7 @@ async function runMattermostReaction(
|
|||
baseUrl,
|
||||
botToken,
|
||||
fetchImpl: params.fetchImpl,
|
||||
allowPrivateNetwork: resolved.config?.allowPrivateNetwork === true,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(resolved.config),
|
||||
});
|
||||
|
||||
const cacheKey = `${baseUrl}:${botToken}`;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
|
|
@ -355,7 +356,7 @@ async function resolveMattermostSendContext(
|
|||
: undefined;
|
||||
const dmRetryOptions = mergeDmRetryOptions(accountRetryConfig, opts.dmRetryOptions);
|
||||
|
||||
const allowPrivateNetwork = account.config.allowPrivateNetwork === true;
|
||||
const allowPrivateNetwork = isPrivateNetworkOptInEnabled(account.config);
|
||||
const channelId = await resolveTargetChannelId({
|
||||
target,
|
||||
baseUrl,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import {
|
||||
|
|
@ -273,7 +274,7 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
|
|||
const client = createMattermostClient({
|
||||
baseUrl: account.baseUrl ?? "",
|
||||
botToken: account.botToken ?? "",
|
||||
allowPrivateNetwork: account.config?.allowPrivateNetwork === true,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
||||
});
|
||||
|
||||
const auth = await authorizeSlashInvocation({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
|
|
@ -82,7 +83,7 @@ export async function resolveMattermostOpaqueTarget(params: {
|
|||
const client = createMattermostClient({
|
||||
baseUrl,
|
||||
botToken: token,
|
||||
allowPrivateNetwork: account?.config?.allowPrivateNetwork === true,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account?.config),
|
||||
});
|
||||
try {
|
||||
await fetchMattermostUser(client, input);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ export type MattermostReplyToMode = "off" | "first" | "all";
|
|||
export type MattermostChatTypeKey = "direct" | "channel" | "group";
|
||||
|
||||
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
||||
export type MattermostNetworkConfig = {
|
||||
/** Dangerous opt-in for self-hosted Mattermost on trusted private/internal hosts. */
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
};
|
||||
|
||||
export type MattermostAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
|
|
@ -86,8 +90,8 @@ export type MattermostAccountConfig = {
|
|||
*/
|
||||
allowedSourceIps?: string[];
|
||||
};
|
||||
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */
|
||||
allowPrivateNetwork?: boolean;
|
||||
/** Network policy overrides for self-hosted Mattermost on trusted private/internal hosts. */
|
||||
network?: MattermostNetworkConfig;
|
||||
/** Retry configuration for DM channel creation */
|
||||
dmChannelRetry?: {
|
||||
/** Maximum number of retry attempts (default: 3) */
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
type OpenClawConfig,
|
||||
} from "./channel-api.js";
|
||||
import { NextcloudTalkConfigSchema } from "./config-schema.js";
|
||||
import { nextcloudTalkDoctor } from "./doctor.js";
|
||||
import { monitorNextcloudTalkProvider } from "./monitor.js";
|
||||
import {
|
||||
looksLikeNextcloudTalkTargetId,
|
||||
|
|
@ -141,6 +142,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|||
}),
|
||||
},
|
||||
auth: nextcloudTalkApprovalAuth,
|
||||
doctor: nextcloudTalkDoctor,
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ export const NextcloudTalkRoomSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const NextcloudTalkNetworkSchema = z
|
||||
.object({
|
||||
/** Dangerous opt-in for self-hosted Nextcloud Talk on trusted private/internal hosts. */
|
||||
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const NextcloudTalkAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
|
|
@ -43,8 +51,8 @@ export const NextcloudTalkAccountSchemaBase = z
|
|||
groupAllowFrom: z.array(z.string()).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(),
|
||||
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Nextcloud on LAN/VPN. */
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
/** Network policy overrides for self-hosted Nextcloud Talk on trusted private/internal hosts. */
|
||||
network: NextcloudTalkNetworkSchema,
|
||||
...ReplyRuntimeConfigSchemaShape,
|
||||
})
|
||||
.strict();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
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", "nextcloud-talk"],
|
||||
message:
|
||||
"channels.nextcloud-talk.allowPrivateNetwork is legacy; use channels.nextcloud-talk.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
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 (auto-migrated on load).",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { nextcloudTalkDoctor } from "./doctor.js";
|
||||
|
||||
describe("nextcloud-talk doctor", () => {
|
||||
it("normalizes legacy private-network aliases", () => {
|
||||
const normalize = nextcloudTalkDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
allowPrivateNetwork: true,
|
||||
accounts: {
|
||||
work: {
|
||||
allowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.["nextcloud-talk"]?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
});
|
||||
expect(result.config.channels?.["nextcloud-talk"]?.accounts?.work?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
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 normalizeNextcloudTalkCompatibilityConfig(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,
|
||||
};
|
||||
}
|
||||
|
||||
const NEXTCLOUD_TALK_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "nextcloud-talk"],
|
||||
message:
|
||||
"channels.nextcloud-talk.allowPrivateNetwork is legacy; use channels.nextcloud-talk.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
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 (auto-migrated on load).",
|
||||
match: hasLegacyAllowPrivateNetworkInAccounts,
|
||||
},
|
||||
];
|
||||
|
||||
export const nextcloudTalkDoctor: ChannelDoctorAdapter = {
|
||||
legacyConfigRules: NEXTCLOUD_TALK_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: ({ cfg }) => normalizeNextcloudTalkCompatibilityConfig(cfg),
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { fetchWithSsrFGuard, type RuntimeEnv } from "../runtime-api.js";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
|
|
@ -111,7 +112,7 @@ export async function resolveNextcloudTalkRoomKind(params: {
|
|||
},
|
||||
},
|
||||
auditContext: "nextcloud-talk.room-info",
|
||||
policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
export { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
export { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
|
||||
export { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
export { resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getNextcloudTalkRuntime,
|
||||
resolveMarkdownTableMode,
|
||||
resolveNextcloudTalkAccount,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
} from "./send.runtime.js";
|
||||
import type { CoreConfig, NextcloudTalkSendResult } from "./types.js";
|
||||
|
||||
|
|
@ -130,7 +131,7 @@ export async function sendMessageNextcloudTalk(
|
|||
body: bodyStr,
|
||||
},
|
||||
auditContext: "nextcloud-talk-send",
|
||||
policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -218,7 +219,7 @@ export async function sendReactionNextcloudTalk(
|
|||
body,
|
||||
},
|
||||
auditContext: "nextcloud-talk-reaction",
|
||||
policy: account.config?.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ export type NextcloudTalkRoomConfig = {
|
|||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type NextcloudTalkNetworkConfig = {
|
||||
/** Dangerous opt-in for self-hosted Nextcloud Talk on trusted private/internal hosts. */
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
};
|
||||
|
||||
export type NextcloudTalkAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
|
|
@ -75,8 +80,8 @@ export type NextcloudTalkAccountConfig = {
|
|||
responsePrefix?: string;
|
||||
/** Media upload max size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Nextcloud on LAN/VPN. */
|
||||
allowPrivateNetwork?: boolean;
|
||||
/** Network policy overrides for self-hosted Nextcloud Talk on trusted private/internal hosts. */
|
||||
network?: NextcloudTalkNetworkConfig;
|
||||
};
|
||||
|
||||
export type NextcloudTalkConfig = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
|
|
@ -15,7 +15,11 @@ export function buildTlonAccountFields(input: TlonAccountFieldsInput) {
|
|||
...(input.url ? { url: input.url } : {}),
|
||||
...(input.code ? { code: input.code } : {}),
|
||||
...(typeof input.allowPrivateNetwork === "boolean"
|
||||
? { allowPrivateNetwork: input.allowPrivateNetwork }
|
||||
? {
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: input.allowPrivateNetwork,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
|
||||
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
resolveTlonSetupConfigured,
|
||||
tlonSetupAdapter,
|
||||
} from "./setup-core.js";
|
||||
import { tlonDoctor } from "./doctor.js";
|
||||
import {
|
||||
formatTargetHint,
|
||||
normalizeShip,
|
||||
|
|
@ -100,6 +101,7 @@ export const tlonPlugin = createChatChannelPlugin({
|
|||
},
|
||||
}),
|
||||
},
|
||||
doctor: tlonDoctor,
|
||||
messaging: {
|
||||
normalizeTarget: (target) => {
|
||||
const parsed = parseTlonTarget(target);
|
||||
|
|
|
|||
|
|
@ -13,13 +13,20 @@ export const TlonAuthorizationSchema = z.object({
|
|||
channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(),
|
||||
});
|
||||
|
||||
const TlonNetworkSchema = z
|
||||
.object({
|
||||
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const tlonCommonConfigFields = {
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
ship: ShipSchema.optional(),
|
||||
url: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
network: TlonNetworkSchema,
|
||||
groupChannels: z.array(ChannelNestSchema).optional(),
|
||||
dmAllowlist: z.array(ShipSchema).optional(),
|
||||
autoDiscoverChannels: z.boolean().optional(),
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ describe("tlon core", () => {
|
|||
]);
|
||||
expect(result.cfg.channels?.tlon?.dmAllowlist).toEqual(["~zod", "~nec"]);
|
||||
expect(result.cfg.channels?.tlon?.autoDiscoverChannels).toBe(true);
|
||||
expect(result.cfg.channels?.tlon?.allowPrivateNetwork).toBe(false);
|
||||
expect(result.cfg.channels?.tlon?.network?.dangerouslyAllowPrivateNetwork).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves dm targets to normalized ships", () => {
|
||||
|
|
|
|||
|
|
@ -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", "tlon"],
|
||||
message:
|
||||
"channels.tlon.allowPrivateNetwork is legacy; use channels.tlon.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
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 (auto-migrated on load).",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { tlonDoctor } from "./doctor.js";
|
||||
|
||||
describe("tlon doctor", () => {
|
||||
it("normalizes legacy private-network aliases", () => {
|
||||
const normalize = tlonDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
tlon: {
|
||||
allowPrivateNetwork: true,
|
||||
accounts: {
|
||||
alt: {
|
||||
allowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.tlon?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
});
|
||||
expect(result.config.channels?.tlon?.accounts?.alt?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 normalizeTlonCompatibilityConfig(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,
|
||||
};
|
||||
}
|
||||
|
||||
const TLON_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "tlon"],
|
||||
message:
|
||||
"channels.tlon.allowPrivateNetwork is legacy; use channels.tlon.network.dangerouslyAllowPrivateNetwork instead (auto-migrated on load).",
|
||||
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 (auto-migrated on load).",
|
||||
match: hasLegacyAllowPrivateNetworkInAccounts,
|
||||
},
|
||||
];
|
||||
|
||||
export const tlonDoctor: ChannelDoctorAdapter = {
|
||||
legacyConfigRules: TLON_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: ({ cfg }) => normalizeTlonCompatibilityConfig(cfg),
|
||||
};
|
||||
|
|
@ -46,7 +46,7 @@ export const tlonSetupWizard = createTlonSetupWizardBase({
|
|||
initialValue: allowPrivateNetwork,
|
||||
});
|
||||
if (!allowPrivateNetwork) {
|
||||
throw new Error("Refusing private/internal Ship URL without explicit approval");
|
||||
throw new Error("Refusing private/internal ship URL without explicit network opt-in");
|
||||
}
|
||||
}
|
||||
next = applyTlonSetupConfig({
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { hasLegacyFlatAllowPrivateNetworkAlias, isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
type TlonAccountConfig = {
|
||||
name?: string;
|
||||
|
|
@ -12,7 +13,9 @@ type TlonAccountConfig = {
|
|||
ship?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
network?: {
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
};
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
groupInviteAllowlist?: string[];
|
||||
|
|
@ -102,7 +105,15 @@ export function resolveTlonAccount(
|
|||
const ship = (merged.ship ?? null) as string | null;
|
||||
const url = (merged.url ?? null) as string | null;
|
||||
const code = (merged.code ?? null) as string | null;
|
||||
const allowPrivateNetwork = (merged.allowPrivateNetwork ?? null) as boolean | null;
|
||||
const allowPrivateNetwork =
|
||||
isPrivateNetworkOptInEnabled(merged)
|
||||
? true
|
||||
: typeof merged.network?.dangerouslyAllowPrivateNetwork === "boolean"
|
||||
? merged.network.dangerouslyAllowPrivateNetwork
|
||||
: hasLegacyFlatAllowPrivateNetworkAlias(merged) &&
|
||||
typeof merged.allowPrivateNetwork === "boolean"
|
||||
? merged.allowPrivateNetwork
|
||||
: null;
|
||||
const groupChannels = (merged.groupChannels ?? []) as string[];
|
||||
const dmAllowlist = (merged.dmAllowlist ?? []) as string[];
|
||||
const groupInviteAllowlist = (merged.groupInviteAllowlist ?? []) as string[];
|
||||
|
|
|
|||
|
|
@ -13,4 +13,24 @@ describe("bundled channel contract surfaces", () => {
|
|||
expect(surface).not.toBeNull();
|
||||
expect(surface?.normalizeTelegramCommandName?.("/Hello-World")).toBe("hello_world");
|
||||
});
|
||||
|
||||
it.each(["matrix", "mattermost", "bluebubbles", "nextcloud-talk", "tlon"])(
|
||||
"exposes legacy migration hooks for %s from a source checkout",
|
||||
(pluginId) => {
|
||||
const surface = getBundledChannelContractSurfaceModule<{
|
||||
normalizeCompatibilityConfig?: (params: { cfg: Record<string, unknown> }) => {
|
||||
config: Record<string, unknown>;
|
||||
changes: string[];
|
||||
};
|
||||
legacyConfigRules?: unknown[];
|
||||
}>({
|
||||
pluginId,
|
||||
preferredBasename: "contract-surfaces.ts",
|
||||
});
|
||||
|
||||
expect(surface).not.toBeNull();
|
||||
expect(surface?.normalizeCompatibilityConfig).toBeTypeOf("function");
|
||||
expect(Array.isArray(surface?.legacyConfigRules)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { applyChannelDoctorCompatibilityMigrations } from "./legacy-config.js";
|
||||
|
||||
describe("bundled channel legacy config migrations", () => {
|
||||
it("normalizes legacy private-network aliases exposed through bundled contract surfaces", () => {
|
||||
const result = applyChannelDoctorCompatibilityMigrations({
|
||||
channels: {
|
||||
mattermost: {
|
||||
allowPrivateNetwork: true,
|
||||
accounts: {
|
||||
work: {
|
||||
allowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.next.channels?.mattermost).toEqual({
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Moved channels.mattermost.allowPrivateNetwork → channels.mattermost.network.dangerouslyAllowPrivateNetwork (true).",
|
||||
"Moved channels.mattermost.accounts.work.allowPrivateNetwork → channels.mattermost.accounts.work.network.dangerouslyAllowPrivateNetwork (false).",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -232,8 +232,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
sendReadReceipts: {
|
||||
type: "boolean",
|
||||
},
|
||||
allowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
network: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dangerouslyAllowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
blockStreaming: {
|
||||
type: "boolean",
|
||||
|
|
@ -503,8 +509,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
sendReadReceipts: {
|
||||
type: "boolean",
|
||||
},
|
||||
allowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
network: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dangerouslyAllowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
blockStreaming: {
|
||||
type: "boolean",
|
||||
|
|
@ -6473,8 +6485,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
homeserver: {
|
||||
type: "string",
|
||||
},
|
||||
allowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
network: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dangerouslyAllowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
proxy: {
|
||||
type: "string",
|
||||
|
|
@ -7272,8 +7290,29 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
allowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
groups: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
requireMention: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
network: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dangerouslyAllowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
dmChannelRetry: {
|
||||
type: "object",
|
||||
|
|
@ -7553,8 +7592,29 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
allowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
groups: {
|
||||
type: "object",
|
||||
propertyNames: {
|
||||
type: "string",
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
requireMention: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
network: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dangerouslyAllowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
dmChannelRetry: {
|
||||
type: "object",
|
||||
|
|
@ -7753,6 +7813,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
typingIndicator: {
|
||||
type: "boolean",
|
||||
},
|
||||
blockStreaming: {
|
||||
type: "boolean",
|
||||
},
|
||||
|
|
@ -8303,8 +8366,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
allowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
network: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dangerouslyAllowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
historyLimit: {
|
||||
type: "integer",
|
||||
|
|
@ -8638,8 +8707,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
allowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
network: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dangerouslyAllowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
historyLimit: {
|
||||
type: "integer",
|
||||
|
|
@ -13905,8 +13980,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
code: {
|
||||
type: "string",
|
||||
},
|
||||
allowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
network: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dangerouslyAllowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
groupChannels: {
|
||||
type: "array",
|
||||
|
|
@ -14001,8 +14082,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
code: {
|
||||
type: "string",
|
||||
},
|
||||
allowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
network: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dangerouslyAllowPrivateNetwork: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
groupChannels: {
|
||||
type: "array",
|
||||
|
|
|
|||
|
|
@ -771,6 +771,75 @@ describe("legacy migrate nested channel enabled aliases", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("legacy migrate bundled channel private-network aliases", () => {
|
||||
it("accepts legacy Mattermost private-network aliases through validation and normalizes them", () => {
|
||||
const raw = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
allowPrivateNetwork: true,
|
||||
accounts: {
|
||||
work: {
|
||||
allowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const validated = validateConfigObjectWithPlugins(raw);
|
||||
expect(validated.ok).toBe(true);
|
||||
if (!validated.ok) {
|
||||
return;
|
||||
}
|
||||
expect(validated.config.channels?.mattermost).toEqual({
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rawValidated = validateConfigObjectRawWithPlugins(raw);
|
||||
expect(rawValidated.ok).toBe(true);
|
||||
if (!rawValidated.ok) {
|
||||
return;
|
||||
}
|
||||
expect(rawValidated.config.channels?.mattermost).toEqual({
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = migrateLegacyConfig(raw);
|
||||
expect(res.changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Moved channels.mattermost.allowPrivateNetwork → channels.mattermost.network.dangerouslyAllowPrivateNetwork (true).",
|
||||
"Moved channels.mattermost.accounts.work.allowPrivateNetwork → channels.mattermost.accounts.work.network.dangerouslyAllowPrivateNetwork (false).",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy migrate x_search auth", () => {
|
||||
it("moves only legacy x_search auth into plugin-owned xai config", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ import type { LookupFn } from "../infra/net/ssrf.js";
|
|||
import {
|
||||
assertHttpUrlTargetsPrivateNetwork,
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
hasLegacyFlatAllowPrivateNetworkAlias,
|
||||
isPrivateNetworkOptInEnabled,
|
||||
isHttpsUrlAllowedByHostnameSuffixAllowlist,
|
||||
migrateLegacyFlatAllowPrivateNetworkAlias,
|
||||
normalizeHostnameSuffixAllowlist,
|
||||
ssrfPolicyFromAllowPrivateNetwork,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
} from "./ssrf-policy.js";
|
||||
|
||||
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
|
||||
|
|
@ -39,6 +43,137 @@ describe("ssrfPolicyFromAllowPrivateNetwork", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("isPrivateNetworkOptInEnabled", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "returns false for missing input",
|
||||
input: undefined,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "returns false for explicit false",
|
||||
input: false,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "returns true for explicit boolean true",
|
||||
input: true,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "returns true for flat allowPrivateNetwork config",
|
||||
input: { allowPrivateNetwork: true },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "returns true for flat dangerous opt-in config",
|
||||
input: { dangerouslyAllowPrivateNetwork: true },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "returns true for nested network dangerous opt-in config",
|
||||
input: { network: { dangerouslyAllowPrivateNetwork: true } },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "returns false for nested false values",
|
||||
input: { network: { dangerouslyAllowPrivateNetwork: false } },
|
||||
expected: false,
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(isPrivateNetworkOptInEnabled(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssrfPolicyFromPrivateNetworkOptIn", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "returns undefined for unset input",
|
||||
input: undefined,
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "returns undefined for explicit false input",
|
||||
input: { allowPrivateNetwork: false },
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "returns the compat policy for nested dangerous input",
|
||||
input: { network: { dangerouslyAllowPrivateNetwork: true } },
|
||||
expected: { allowPrivateNetwork: true },
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(ssrfPolicyFromPrivateNetworkOptIn(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy private-network alias helpers", () => {
|
||||
it("detects the flat allowPrivateNetwork alias", () => {
|
||||
expect(hasLegacyFlatAllowPrivateNetworkAlias({ allowPrivateNetwork: true })).toBe(true);
|
||||
expect(hasLegacyFlatAllowPrivateNetworkAlias({ network: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("migrates the flat alias into network.dangerouslyAllowPrivateNetwork", () => {
|
||||
const changes: string[] = [];
|
||||
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: { allowPrivateNetwork: true },
|
||||
pathPrefix: "channels.matrix",
|
||||
changes,
|
||||
});
|
||||
|
||||
expect(migrated.entry).toEqual({
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
expect(changes).toEqual([
|
||||
"Moved channels.matrix.allowPrivateNetwork → channels.matrix.network.dangerouslyAllowPrivateNetwork (true).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers the canonical network key when both old and new keys are present", () => {
|
||||
const changes: string[] = [];
|
||||
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: {
|
||||
allowPrivateNetwork: true,
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
pathPrefix: "channels.matrix.accounts.default",
|
||||
changes,
|
||||
});
|
||||
|
||||
expect(migrated.entry).toEqual({
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
});
|
||||
expect(changes[0]).toContain("(false)");
|
||||
});
|
||||
|
||||
it("keeps an explicit canonical true when the legacy key is false", () => {
|
||||
const changes: string[] = [];
|
||||
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: {
|
||||
allowPrivateNetwork: false,
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
},
|
||||
pathPrefix: "channels.matrix.accounts.default",
|
||||
changes,
|
||||
});
|
||||
|
||||
expect(migrated.entry).toEqual({
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
expect(changes[0]).toContain("(true)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertHttpUrlTargetsPrivateNetwork", () => {
|
||||
it.each([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,10 +9,101 @@ import {
|
|||
export { isPrivateIpAddress };
|
||||
export type { SsrFPolicy };
|
||||
|
||||
export type PrivateNetworkOptInInput =
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| Pick<SsrFPolicy, "allowPrivateNetwork" | "dangerouslyAllowPrivateNetwork">
|
||||
| {
|
||||
allowPrivateNetwork?: boolean | null;
|
||||
dangerouslyAllowPrivateNetwork?: boolean | null;
|
||||
network?:
|
||||
| Pick<SsrFPolicy, "allowPrivateNetwork" | "dangerouslyAllowPrivateNetwork">
|
||||
| null
|
||||
| undefined;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isPrivateNetworkOptInEnabled(input: PrivateNetworkOptInInput): boolean {
|
||||
if (input === true) {
|
||||
return true;
|
||||
}
|
||||
const record = asRecord(input);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
const network = asRecord(record.network);
|
||||
return (
|
||||
record.allowPrivateNetwork === true ||
|
||||
record.dangerouslyAllowPrivateNetwork === true ||
|
||||
network?.allowPrivateNetwork === true ||
|
||||
network?.dangerouslyAllowPrivateNetwork === true
|
||||
);
|
||||
}
|
||||
|
||||
export function ssrfPolicyFromPrivateNetworkOptIn(
|
||||
input: PrivateNetworkOptInInput,
|
||||
): SsrFPolicy | undefined {
|
||||
return isPrivateNetworkOptInEnabled(input) ? { allowPrivateNetwork: true } : undefined;
|
||||
}
|
||||
|
||||
export function hasLegacyFlatAllowPrivateNetworkAlias(value: unknown): boolean {
|
||||
const entry = asRecord(value);
|
||||
return Boolean(entry && Object.prototype.hasOwnProperty.call(entry, "allowPrivateNetwork"));
|
||||
}
|
||||
|
||||
export function migrateLegacyFlatAllowPrivateNetworkAlias(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
if (!hasLegacyFlatAllowPrivateNetworkAlias(params.entry)) {
|
||||
return { entry: params.entry, changed: false };
|
||||
}
|
||||
|
||||
const legacyAllowPrivateNetwork = params.entry.allowPrivateNetwork;
|
||||
const currentNetworkRecord = asRecord(params.entry.network);
|
||||
const currentNetwork = currentNetworkRecord ? { ...currentNetworkRecord } : {};
|
||||
const currentDangerousAllowPrivateNetwork = currentNetwork.dangerouslyAllowPrivateNetwork;
|
||||
|
||||
let resolvedDangerousAllowPrivateNetwork: unknown = currentDangerousAllowPrivateNetwork;
|
||||
if (typeof currentDangerousAllowPrivateNetwork === "boolean") {
|
||||
// The canonical key wins when both shapes are present.
|
||||
resolvedDangerousAllowPrivateNetwork = currentDangerousAllowPrivateNetwork;
|
||||
} else if (typeof legacyAllowPrivateNetwork === "boolean") {
|
||||
resolvedDangerousAllowPrivateNetwork = legacyAllowPrivateNetwork;
|
||||
} else if (currentDangerousAllowPrivateNetwork === undefined) {
|
||||
resolvedDangerousAllowPrivateNetwork = legacyAllowPrivateNetwork;
|
||||
}
|
||||
|
||||
delete currentNetwork.dangerouslyAllowPrivateNetwork;
|
||||
if (resolvedDangerousAllowPrivateNetwork !== undefined) {
|
||||
currentNetwork.dangerouslyAllowPrivateNetwork = resolvedDangerousAllowPrivateNetwork;
|
||||
}
|
||||
|
||||
const nextEntry = { ...params.entry };
|
||||
delete nextEntry.allowPrivateNetwork;
|
||||
if (Object.keys(currentNetwork).length > 0) {
|
||||
nextEntry.network = currentNetwork;
|
||||
} else {
|
||||
delete nextEntry.network;
|
||||
}
|
||||
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.allowPrivateNetwork → ${params.pathPrefix}.network.dangerouslyAllowPrivateNetwork (${String(resolvedDangerousAllowPrivateNetwork)}).`,
|
||||
);
|
||||
return { entry: nextEntry, changed: true };
|
||||
}
|
||||
|
||||
export function ssrfPolicyFromAllowPrivateNetwork(
|
||||
allowPrivateNetwork: boolean | null | undefined,
|
||||
): SsrFPolicy | undefined {
|
||||
return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
|
||||
return ssrfPolicyFromPrivateNetworkOptIn(allowPrivateNetwork);
|
||||
}
|
||||
|
||||
export async function assertHttpUrlTargetsPrivateNetwork(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
|
|||
export {
|
||||
assertHttpUrlTargetsPrivateNetwork,
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
hasLegacyFlatAllowPrivateNetworkAlias,
|
||||
isPrivateNetworkOptInEnabled,
|
||||
migrateLegacyFlatAllowPrivateNetworkAlias,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
ssrfPolicyFromAllowPrivateNetwork,
|
||||
} from "./ssrf-policy.js";
|
||||
export { isPrivateOrLoopbackHost } from "../gateway/net.js";
|
||||
|
|
|
|||
Loading…
Reference in New Issue