refactor: unify channel plugin resolution, family ordering, and changelog entry tooling

This commit is contained in:
Peter Steinberger
2026-02-24 15:15:11 +00:00
parent 878b4e0ed7
commit d18ae2256f
7 changed files with 310 additions and 156 deletions

View File

@@ -38,6 +38,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
3. **Changelog & docs**
- [ ] Update `CHANGELOG.md` with user-facing highlights (create the file if missing); keep entries strictly descending by version.
- Tip: use `pnpm changelog:add -- --section fixes --entry "Your entry. (#12345) Thanks @contrib."` (or `--section changes`) to append deterministically under the current Unreleased block.
- [ ] Ensure README examples/flags match current CLI behavior (notably new commands or options).
4. **Validation**

View File

@@ -54,6 +54,7 @@
"build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"changelog:add": "node --import tsx scripts/changelog-add.ts",
"check": "pnpm format:check && pnpm tsgo && pnpm lint",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",

123
scripts/changelog-add.ts Normal file
View File

@@ -0,0 +1,123 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
export type UnreleasedSection = "changes" | "fixes";
function normalizeEntry(entry: string): string {
const trimmed = entry.trim();
if (!trimmed) {
throw new Error("entry must not be empty");
}
return trimmed.startsWith("- ") ? trimmed : `- ${trimmed}`;
}
function sectionHeading(section: UnreleasedSection): string {
return section === "changes" ? "### Changes" : "### Fixes";
}
export function insertUnreleasedChangelogEntry(
changelogContent: string,
section: UnreleasedSection,
entry: string,
): string {
const normalizedEntry = normalizeEntry(entry);
const lines = changelogContent.split(/\r?\n/);
const unreleasedHeaderIndex = lines.findIndex((line) =>
/^##\s+.+\s+\(Unreleased\)\s*$/.test(line.trim()),
);
if (unreleasedHeaderIndex < 0) {
throw new Error("could not find an '(Unreleased)' changelog section");
}
const unreleasedEndIndex = lines.findIndex(
(line, index) => index > unreleasedHeaderIndex && /^##\s+/.test(line.trim()),
);
const unreleasedLimit = unreleasedEndIndex < 0 ? lines.length : unreleasedEndIndex;
const sectionLabel = sectionHeading(section);
const sectionStartIndex = lines.findIndex(
(line, index) =>
index > unreleasedHeaderIndex && index < unreleasedLimit && line.trim() === sectionLabel,
);
if (sectionStartIndex < 0) {
throw new Error(`could not find '${sectionLabel}' under unreleased section`);
}
const sectionEndIndex = lines.findIndex(
(line, index) =>
index > sectionStartIndex &&
index < unreleasedLimit &&
(/^###\s+/.test(line.trim()) || /^##\s+/.test(line.trim())),
);
const targetIndex = sectionEndIndex < 0 ? unreleasedLimit : sectionEndIndex;
let insertionIndex = targetIndex;
while (insertionIndex > sectionStartIndex + 1 && lines[insertionIndex - 1].trim() === "") {
insertionIndex -= 1;
}
if (
lines.slice(sectionStartIndex + 1, targetIndex).some((line) => line.trim() === normalizedEntry)
) {
return changelogContent;
}
lines.splice(insertionIndex, 0, normalizedEntry);
return `${lines.join("\n")}\n`;
}
type CliArgs = {
section: UnreleasedSection;
entry: string;
file: string;
};
function parseCliArgs(argv: string[]): CliArgs {
let section: UnreleasedSection | null = null;
let entry = "";
let file = "CHANGELOG.md";
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--section") {
const value = argv[i + 1];
if (value !== "changes" && value !== "fixes") {
throw new Error("--section must be one of: changes, fixes");
}
section = value;
i += 1;
continue;
}
if (arg === "--entry") {
entry = argv[i + 1] ?? "";
i += 1;
continue;
}
if (arg === "--file") {
file = argv[i + 1] ?? file;
i += 1;
continue;
}
throw new Error(`unknown argument: ${arg}`);
}
if (!section) {
throw new Error("missing --section <changes|fixes>");
}
if (!entry.trim()) {
throw new Error("missing --entry <text>");
}
return { section, entry, file };
}
function runCli(): void {
const args = parseCliArgs(process.argv.slice(2));
const changelogPath = resolve(process.cwd(), args.file);
const content = readFileSync(changelogPath, "utf8");
const next = insertUnreleasedChangelogEntry(content, args.section, args.entry);
if (next !== content) {
writeFileSync(changelogPath, next, "utf8");
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
runCli();
}

View File

@@ -49,7 +49,7 @@ function recordHasKeys(value: unknown): boolean {
return isRecord(value) && Object.keys(value).length > 0;
}
function accountsHaveKeys(value: unknown, keys: string[]): boolean {
function accountsHaveKeys(value: unknown, keys: readonly string[]): boolean {
if (!isRecord(value)) {
return false;
}
@@ -75,108 +75,95 @@ function resolveChannelConfig(
return isRecord(entry) ? entry : null;
}
function isTelegramConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
if (hasNonEmptyString(env.TELEGRAM_BOT_TOKEN)) {
return true;
type StructuredChannelConfigSpec = {
envAny?: readonly string[];
envAll?: readonly string[];
stringKeys?: readonly string[];
numberKeys?: readonly string[];
accountStringKeys?: readonly string[];
};
const STRUCTURED_CHANNEL_CONFIG_SPECS: Record<string, StructuredChannelConfigSpec> = {
telegram: {
envAny: ["TELEGRAM_BOT_TOKEN"],
stringKeys: ["botToken", "tokenFile"],
accountStringKeys: ["botToken", "tokenFile"],
},
discord: {
envAny: ["DISCORD_BOT_TOKEN"],
stringKeys: ["token"],
accountStringKeys: ["token"],
},
irc: {
envAll: ["IRC_HOST", "IRC_NICK"],
stringKeys: ["host", "nick"],
accountStringKeys: ["host", "nick"],
},
slack: {
envAny: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"],
stringKeys: ["botToken", "appToken", "userToken"],
accountStringKeys: ["botToken", "appToken", "userToken"],
},
signal: {
stringKeys: ["account", "httpUrl", "httpHost", "cliPath"],
numberKeys: ["httpPort"],
accountStringKeys: ["account", "httpUrl", "httpHost", "cliPath"],
},
imessage: {
stringKeys: ["cliPath"],
},
};
function envHasAnyKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean {
for (const key of keys) {
if (hasNonEmptyString(env[key])) {
return true;
}
}
const entry = resolveChannelConfig(cfg, "telegram");
if (!entry) {
return false;
}
if (hasNonEmptyString(entry.botToken) || hasNonEmptyString(entry.tokenFile)) {
return true;
}
if (accountsHaveKeys(entry.accounts, ["botToken", "tokenFile"])) {
return true;
}
return recordHasKeys(entry);
return false;
}
function isDiscordConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
if (hasNonEmptyString(env.DISCORD_BOT_TOKEN)) {
return true;
function envHasAllKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean {
for (const key of keys) {
if (!hasNonEmptyString(env[key])) {
return false;
}
}
const entry = resolveChannelConfig(cfg, "discord");
if (!entry) {
return false;
}
if (hasNonEmptyString(entry.token)) {
return true;
}
if (accountsHaveKeys(entry.accounts, ["token"])) {
return true;
}
return recordHasKeys(entry);
return keys.length > 0;
}
function isIrcConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
if (hasNonEmptyString(env.IRC_HOST) && hasNonEmptyString(env.IRC_NICK)) {
return true;
function hasAnyNumberKeys(entry: Record<string, unknown>, keys: readonly string[]): boolean {
for (const key of keys) {
if (typeof entry[key] === "number") {
return true;
}
}
const entry = resolveChannelConfig(cfg, "irc");
if (!entry) {
return false;
}
if (hasNonEmptyString(entry.host) || hasNonEmptyString(entry.nick)) {
return true;
}
if (accountsHaveKeys(entry.accounts, ["host", "nick"])) {
return true;
}
return recordHasKeys(entry);
return false;
}
function isSlackConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
if (
hasNonEmptyString(env.SLACK_BOT_TOKEN) ||
hasNonEmptyString(env.SLACK_APP_TOKEN) ||
hasNonEmptyString(env.SLACK_USER_TOKEN)
) {
function isStructuredChannelConfigured(
cfg: OpenClawConfig,
channelId: string,
env: NodeJS.ProcessEnv,
spec: StructuredChannelConfigSpec,
): boolean {
if (spec.envAny && envHasAnyKeys(env, spec.envAny)) {
return true;
}
const entry = resolveChannelConfig(cfg, "slack");
if (spec.envAll && envHasAllKeys(env, spec.envAll)) {
return true;
}
const entry = resolveChannelConfig(cfg, channelId);
if (!entry) {
return false;
}
if (
hasNonEmptyString(entry.botToken) ||
hasNonEmptyString(entry.appToken) ||
hasNonEmptyString(entry.userToken)
) {
if (spec.stringKeys && spec.stringKeys.some((key) => hasNonEmptyString(entry[key]))) {
return true;
}
if (accountsHaveKeys(entry.accounts, ["botToken", "appToken", "userToken"])) {
if (spec.numberKeys && hasAnyNumberKeys(entry, spec.numberKeys)) {
return true;
}
return recordHasKeys(entry);
}
function isSignalConfigured(cfg: OpenClawConfig): boolean {
const entry = resolveChannelConfig(cfg, "signal");
if (!entry) {
return false;
}
if (
hasNonEmptyString(entry.account) ||
hasNonEmptyString(entry.httpUrl) ||
hasNonEmptyString(entry.httpHost) ||
typeof entry.httpPort === "number" ||
hasNonEmptyString(entry.cliPath)
) {
return true;
}
if (accountsHaveKeys(entry.accounts, ["account", "httpUrl", "httpHost", "cliPath"])) {
return true;
}
return recordHasKeys(entry);
}
function isIMessageConfigured(cfg: OpenClawConfig): boolean {
const entry = resolveChannelConfig(cfg, "imessage");
if (!entry) {
return false;
}
if (hasNonEmptyString(entry.cliPath)) {
if (spec.accountStringKeys && accountsHaveKeys(entry.accounts, spec.accountStringKeys)) {
return true;
}
return recordHasKeys(entry);
@@ -203,24 +190,14 @@ export function isChannelConfigured(
channelId: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
switch (channelId) {
case "whatsapp":
return isWhatsAppConfigured(cfg);
case "telegram":
return isTelegramConfigured(cfg, env);
case "discord":
return isDiscordConfigured(cfg, env);
case "irc":
return isIrcConfigured(cfg, env);
case "slack":
return isSlackConfigured(cfg, env);
case "signal":
return isSignalConfigured(cfg);
case "imessage":
return isIMessageConfigured(cfg);
default:
return isGenericChannelConfigured(cfg, channelId);
if (channelId === "whatsapp") {
return isWhatsAppConfigured(cfg);
}
const spec = STRUCTURED_CHANNEL_CONFIG_SPECS[channelId];
if (spec) {
return isStructuredChannelConfigured(cfg, channelId, env, spec);
}
return isGenericChannelConfigured(cfg, channelId);
}
function collectModelRefs(cfg: OpenClawConfig): string[] {
@@ -325,10 +302,34 @@ function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map<string
return map;
}
type ChannelPluginPair = {
channelId: string;
pluginId: string;
};
function resolvePluginIdForChannel(
channelId: string,
channelToPluginId: ReadonlyMap<string, string>,
): string {
// Third-party plugins can expose a channel id that differs from their
// manifest id; plugins.entries must always be keyed by manifest id.
const builtInId = normalizeChatChannelId(channelId);
if (builtInId) {
return builtInId;
}
return channelToPluginId.get(channelId) ?? channelId;
}
function collectCandidateChannelIds(cfg: OpenClawConfig): string[] {
const channelIds = new Set<string>(CHANNEL_PLUGIN_IDS);
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
if (!configuredChannels || typeof configuredChannels !== "object") {
return Array.from(channelIds);
}
for (const key of Object.keys(configuredChannels)) {
if (key === "defaults" || key === "modelByChannel") {
continue;
}
const normalizedBuiltIn = normalizeChatChannelId(key);
channelIds.add(normalizedBuiltIn ?? key);
}
return Array.from(channelIds);
}
function resolveConfiguredPlugins(
cfg: OpenClawConfig,
@@ -337,45 +338,9 @@ function resolveConfiguredPlugins(
): PluginEnableChange[] {
const changes: PluginEnableChange[] = [];
// Build reverse map: channel ID → plugin ID from installed plugin manifests.
// This is needed when a third-party plugin declares a channel ID that differs
// from the plugin's own ID (e.g. plugin id="apn-channel", channels=["apn"]).
const channelToPluginId = buildChannelToPluginIdMap(registry);
// For built-in and catalog entries: channelId === pluginId (they are the same).
const pairs: ChannelPluginPair[] = CHANNEL_PLUGIN_IDS.map((id) => ({
channelId: id,
pluginId: id,
}));
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
if (configuredChannels && typeof configuredChannels === "object") {
for (const key of Object.keys(configuredChannels)) {
if (key === "defaults" || key === "modelByChannel") {
continue;
}
const builtInId = normalizeChatChannelId(key);
if (builtInId) {
// Built-in channel: channelId and pluginId are the same.
pairs.push({ channelId: builtInId, pluginId: builtInId });
} else {
// Third-party channel plugin: look up the actual plugin ID from the
// manifest registry. If the plugin declares channels=["apn"] but its
// id is "apn-channel", we must use "apn-channel" as the pluginId so
// that plugins.entries is keyed correctly. Fall back to the channel key
// when no installed manifest declares this channel.
const pluginId = channelToPluginId.get(key) ?? key;
pairs.push({ channelId: key, pluginId });
}
}
}
// Deduplicate by channelId, preserving first occurrence.
const seenChannelIds = new Set<string>();
for (const { channelId, pluginId } of pairs) {
if (!channelId || !pluginId || seenChannelIds.has(channelId)) {
continue;
}
seenChannelIds.add(channelId);
for (const channelId of collectCandidateChannelIds(cfg)) {
const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId);
if (isChannelConfigured(cfg, channelId, env)) {
changes.push({ pluginId, reason: `${channelId} configured` });
}

View File

@@ -172,6 +172,16 @@ describe("ssrf pinning", () => {
]);
});
it("uses DNS family metadata for ordering (not address string heuristics)", async () => {
const lookup = vi.fn(async () => [
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 4 },
{ address: "93.184.216.34", family: 6 },
]) as unknown as LookupFn;
const pinned = await resolvePinnedHostname("example.com", lookup);
expect(pinned.addresses).toEqual(["2606:2800:220:1:248:1893:25c8:1946", "93.184.216.34"]);
});
it("allows ISATAP embedded private IPv4 when private network is explicitly enabled", async () => {
const lookup = vi.fn(async () => [
{ address: "2001:db8:1234::5efe:127.0.0.1", family: 6 },

View File

@@ -255,6 +255,24 @@ export type PinnedHostname = {
lookup: typeof dnsLookupCb;
};
function dedupeAndPreferIpv4(results: readonly LookupAddress[]): string[] {
const seen = new Set<string>();
const ipv4: string[] = [];
const otherFamilies: string[] = [];
for (const entry of results) {
if (seen.has(entry.address)) {
continue;
}
seen.add(entry.address);
if (entry.family === 4) {
ipv4.push(entry.address);
continue;
}
otherFamilies.push(entry.address);
}
return [...ipv4, ...otherFamilies];
}
export async function resolvePinnedHostnameWithPolicy(
hostname: string,
params: { lookupFn?: LookupFn; policy?: SsrFPolicy } = {},
@@ -290,18 +308,9 @@ export async function resolvePinnedHostnameWithPolicy(
assertAllowedResolvedAddressesOrThrow(results, params.policy);
}
// Sort IPv4 addresses before IPv6 so that Happy Eyeballs (autoSelectFamily) and
// round-robin pinned lookups try IPv4 first. This avoids connection failures on
// hosts where IPv6 is configured but not routed (common on cloud VMs and WSL2).
// See: https://github.com/openclaw/openclaw/issues/23975
const addresses = Array.from(new Set(results.map((entry) => entry.address))).toSorted((a, b) => {
const aIsV6 = a.includes(":");
const bIsV6 = b.includes(":");
if (aIsV6 === bIsV6) {
return 0;
}
return aIsV6 ? 1 : -1;
});
// Prefer addresses returned as IPv4 by DNS family metadata before other
// families so Happy Eyeballs and pinned round-robin both attempt IPv4 first.
const addresses = dedupeAndPreferIpv4(results);
if (addresses.length === 0) {
throw new Error(`Unable to resolve hostname: ${hostname}`);
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { insertUnreleasedChangelogEntry } from "../../scripts/changelog-add.ts";
const SAMPLE = `# Changelog
## 2026.2.24 (Unreleased)
### Changes
- Existing change.
### Fixes
- Existing fix.
## 2026.2.23
### Changes
- Older entry.
`;
describe("changelog-add", () => {
it("inserts a new unreleased fixes entry before the next version section", () => {
const next = insertUnreleasedChangelogEntry(
SAMPLE,
"fixes",
"New fix entry. (#123) Thanks @someone.",
);
expect(next).toContain(
"- Existing fix.\n- New fix entry. (#123) Thanks @someone.\n\n## 2026.2.23",
);
});
it("normalizes missing bullet prefix", () => {
const next = insertUnreleasedChangelogEntry(SAMPLE, "changes", "New change.");
expect(next).toContain("- Existing change.\n- New change.\n\n### Fixes");
});
it("does not duplicate identical entry", () => {
const once = insertUnreleasedChangelogEntry(SAMPLE, "fixes", "New fix.");
const twice = insertUnreleasedChangelogEntry(once, "fixes", "New fix.");
expect(twice).toBe(once);
});
});