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

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

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

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

* fix(config): expose bundled channel migration hooks

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

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

View File

@@ -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 });

View File

@@ -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();

View File

@@ -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,
};
}

View File

@@ -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,
});
});
});

View File

@@ -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),
};

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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 = {