diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 6b5dc29c9b9..163759e7513 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -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** diff --git a/package.json b/package.json index 66a60a5dc00..76d0868422f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/changelog-add.ts b/scripts/changelog-add.ts new file mode 100644 index 00000000000..2422a00ef4b --- /dev/null +++ b/scripts/changelog-add.ts @@ -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 "); + } + if (!entry.trim()) { + throw new Error("missing --entry "); + } + 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(); +} diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 153f0b304d1..554e96843bc 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -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 = { + 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, 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 { + // 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(CHANNEL_PLUGIN_IDS); + const configuredChannels = cfg.channels as Record | 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 | 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(); - 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` }); } diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 73f91d9d536..28420ea373f 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -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 }, diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 0d77bfeb35d..b84469390c0 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -255,6 +255,24 @@ export type PinnedHostname = { lookup: typeof dnsLookupCb; }; +function dedupeAndPreferIpv4(results: readonly LookupAddress[]): string[] { + const seen = new Set(); + 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}`); } diff --git a/test/scripts/changelog-add.test.ts b/test/scripts/changelog-add.test.ts new file mode 100644 index 00000000000..f9c0d4755d8 --- /dev/null +++ b/test/scripts/changelog-add.test.ts @@ -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); + }); +});