From 5b06c8c6e3a150b564977e632407c320e70f3114 Mon Sep 17 00:00:00 2001 From: Mark L <73659136+liuxiaopai-ai@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:53:00 +0800 Subject: [PATCH] fix(config): normalize gateway bind host aliases during migration (#30855) * fix(config): normalize gateway bind host aliases during migration [AI-assisted] * config(legacy): detect gateway.bind host aliases as legacy * config(legacy): sanitize bind alias migration log output * test(config): cover bind alias legacy detection and log escaping * config(legacy): add source-literal gate to legacy rules * config(legacy): make issue detection source-aware * config(legacy): require source-literal gateway.bind alias detection * config(io): pass parsed source to legacy issue detection * test(config): cover resolved-only gateway.bind alias legacy detection * changelog: format after #30855 rebase conflict resolution --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + src/config/config-misc.test.ts | 47 +++++++++++++++++++ ...etection.rejects-routing-allowfrom.test.ts | 44 +++++++++++++++++ src/config/io.ts | 4 +- src/config/legacy.migrations.part-1.ts | 44 +++++++++++++++++ src/config/legacy.rules.ts | 36 ++++++++++++++ src/config/legacy.shared.ts | 3 ++ src/config/legacy.ts | 33 +++++++++---- 8 files changed, 202 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a1764ccaa..c7160360e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -239,6 +239,7 @@ Docs: https://docs.openclaw.ai - Gateway/Plugin HTTP auth hardening: require gateway auth for protected plugin paths and explicit `registerHttpRoute` paths (while preserving wildcard-handler behavior for signature-auth webhooks), and run plugin handlers after built-in handlers for deterministic route precedence. Landed from contributor PR #29198 by @Mariana-Codebase. Thanks @Mariana-Codebase. - Gateway/Config patch guard: reject `config.patch` updates that set non-loopback `gateway.bind` while `gateway.tailscale.mode` is `serve`/`funnel`, preventing restart crash loops from invalid bind/tailscale combinations. Landed from contributor PR #30910 by @liuxiaopai-ai. Thanks @liuxiaopai-ai. - Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks @ningding97. +- Config/Legacy gateway bind aliases: normalize host-style `gateway.bind` values (`0.0.0.0`/`::`/`127.0.0.1`/`localhost`) to supported bind modes (`lan`/`loopback`) during legacy migration so older configs recover without manual edits. (#30080) Thanks @liuxiaopai-ai and @vincentkoc. - File tools/tilde paths: expand `~/...` against the user home directory before workspace-root checks in host file read/write/edit paths, while preserving root-boundary enforcement so outside-root targets remain blocked. (#29779) Thanks @Glucksberg. - Slack/HTTP mode startup: treat Slack HTTP accounts as configured when `botToken` + `signingSecret` are present (without requiring `appToken`) in channel config/runtime status so webhook mode is not silently skipped. (#30567) Thanks @liuxiaopai-ai. - Slack/Transient request errors: classify Slack request-error messages like `Client network socket disconnected before secure TLS connection was established` as transient in unhandled-rejection fatal detection, preventing temporary network drops from crash-looping the gateway. (#23169) Thanks @graysurf. diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index ee083efadd7..94daa1523b9 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -321,4 +321,51 @@ describe("config strict validation", () => { expect(snap.legacyIssues).not.toHaveLength(0); }); }); + + it("does not mark resolved-only gateway.bind aliases as auto-migratable legacy", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify({ + gateway: { bind: "${OPENCLAW_BIND}" }, + }), + "utf-8", + ); + + const prev = process.env.OPENCLAW_BIND; + process.env.OPENCLAW_BIND = "0.0.0.0"; + try { + const snap = await readConfigFileSnapshot(); + expect(snap.valid).toBe(false); + expect(snap.legacyIssues).toHaveLength(0); + expect(snap.issues.some((issue) => issue.path === "gateway.bind")).toBe(true); + } finally { + if (prev === undefined) { + delete process.env.OPENCLAW_BIND; + } else { + process.env.OPENCLAW_BIND = prev; + } + } + }); + }); + + it("still marks literal gateway.bind host aliases as legacy", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify({ + gateway: { bind: "0.0.0.0" }, + }), + "utf-8", + ); + + const snap = await readConfigFileSnapshot(); + expect(snap.valid).toBe(false); + expect(snap.legacyIssues.some((issue) => issue.path === "gateway.bind")).toBe(true); + }); + }); }); diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index 6e89928a043..f2b2405706e 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -377,6 +377,50 @@ describe("legacy config detection", () => { expect(validated.config.gateway?.bind).toBe("tailnet"); } }); + it("normalizes gateway.bind host aliases to supported bind modes", async () => { + const cases = [ + { input: "0.0.0.0", expected: "lan" }, + { input: "::", expected: "lan" }, + { input: "127.0.0.1", expected: "loopback" }, + { input: "localhost", expected: "loopback" }, + { input: "::1", expected: "loopback" }, + ] as const; + + for (const testCase of cases) { + const res = migrateLegacyConfig({ + gateway: { bind: testCase.input }, + }); + expect(res.changes).toContain( + `Normalized gateway.bind "${testCase.input}" → "${testCase.expected}".`, + ); + expect(res.config?.gateway?.bind).toBe(testCase.expected); + + const validated = validateConfigObject(res.config); + expect(validated.ok).toBe(true); + if (validated.ok) { + expect(validated.config.gateway?.bind).toBe(testCase.expected); + } + } + }); + it("flags gateway.bind host aliases as legacy to trigger auto-migration paths", async () => { + const cases = ["0.0.0.0", "::", "127.0.0.1", "localhost", "::1"] as const; + for (const bind of cases) { + const validated = validateConfigObject({ gateway: { bind } }); + expect(validated.ok, bind).toBe(false); + if (!validated.ok) { + expect( + validated.issues.some((issue) => issue.path === "gateway.bind"), + bind, + ).toBe(true); + } + } + }); + it("escapes control characters in gateway.bind migration change text", async () => { + const res = migrateLegacyConfig({ + gateway: { bind: "\r\n0.0.0.0\r\n" }, + }); + expect(res.changes).toContain('Normalized gateway.bind "\\r\\n0.0.0.0\\r\\n" → "lan".'); + }); it('enforces dmPolicy="open" allowFrom wildcard for supported providers', async () => { const cases = [ { diff --git a/src/config/io.ts b/src/config/io.ts index 136ea5eae6c..cf030e11b75 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -925,7 +925,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } const resolvedConfigRaw = readResolution.resolvedConfigRaw; - const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw); + // Detect legacy keys on resolved config, but only mark source-literal legacy + // entries (for auto-migration) when they are present in the parsed source. + const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw, parsedRes.parsed); const validated = validateConfigObjectWithPlugins(resolvedConfigRaw); if (!validated.ok) { diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.part-1.ts index 70e6dadbbfa..d1d077cafab 100644 --- a/src/config/legacy.migrations.part-1.ts +++ b/src/config/legacy.migrations.part-1.ts @@ -59,6 +59,10 @@ function hasOwnKey(target: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(target, key); } +function escapeControlForLog(value: string): string { + return value.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t"); +} + function migrateThreadBindingsTtlHoursForPath(params: { owner: Record; pathPrefix: string; @@ -535,6 +539,46 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ raw.gateway = gatewayObj; }, }, + { + id: "gateway.bind.host-alias->bind-mode", + describe: "Normalize gateway.bind host aliases to supported bind modes", + apply: (raw, changes) => { + const gateway = getRecord(raw.gateway); + if (!gateway) { + return; + } + const bindRaw = gateway.bind; + if (typeof bindRaw !== "string") { + return; + } + + const normalized = bindRaw.trim().toLowerCase(); + let mapped: "lan" | "loopback" | undefined; + if ( + normalized === "0.0.0.0" || + normalized === "::" || + normalized === "[::]" || + normalized === "*" + ) { + mapped = "lan"; + } else if ( + normalized === "127.0.0.1" || + normalized === "localhost" || + normalized === "::1" || + normalized === "[::1]" + ) { + mapped = "loopback"; + } + + if (!mapped || normalized === mapped) { + return; + } + + gateway.bind = mapped; + raw.gateway = gateway; + changes.push(`Normalized gateway.bind "${escapeControlForLog(bindRaw)}" → "${mapped}".`); + }, + }, { id: "telegram.requireMention->channels.telegram.groups.*.requireMention", describe: "Move telegram.requireMention to channels.telegram.groups.*.requireMention", diff --git a/src/config/legacy.rules.ts b/src/config/legacy.rules.ts index 2e34e440017..9f4ef6098be 100644 --- a/src/config/legacy.rules.ts +++ b/src/config/legacy.rules.ts @@ -17,6 +17,35 @@ function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean { ); } +function isLegacyGatewayBindHostAlias(value: unknown): boolean { + if (typeof value !== "string") { + return false; + } + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return false; + } + if ( + normalized === "auto" || + normalized === "loopback" || + normalized === "lan" || + normalized === "tailnet" || + normalized === "custom" + ) { + return false; + } + return ( + normalized === "0.0.0.0" || + normalized === "::" || + normalized === "[::]" || + normalized === "*" || + normalized === "127.0.0.1" || + normalized === "localhost" || + normalized === "::1" || + normalized === "[::1]" + ); +} + export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ { path: ["whatsapp"], @@ -168,4 +197,11 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ path: ["gateway", "token"], message: "gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).", }, + { + path: ["gateway", "bind"], + message: + "gateway.bind host aliases (for example 0.0.0.0/localhost) are legacy; use bind modes (lan/loopback/custom/tailnet/auto) instead (auto-migrated on load).", + match: (value) => isLegacyGatewayBindHostAlias(value), + requireSourceLiteral: true, + }, ]; diff --git a/src/config/legacy.shared.ts b/src/config/legacy.shared.ts index 9a7e33c8f3f..3fed957d4fd 100644 --- a/src/config/legacy.shared.ts +++ b/src/config/legacy.shared.ts @@ -2,6 +2,9 @@ export type LegacyConfigRule = { path: string[]; message: string; match?: (value: unknown, root: Record) => boolean; + // If true, only report when the legacy value is present in the original parsed + // source (not only after include/env resolution). + requireSourceLiteral?: boolean; }; export type LegacyConfigMigration = { diff --git a/src/config/legacy.ts b/src/config/legacy.ts index 4f34fb95631..deb4458d653 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -2,22 +2,37 @@ import { LEGACY_CONFIG_MIGRATIONS } from "./legacy.migrations.js"; import { LEGACY_CONFIG_RULES } from "./legacy.rules.js"; import type { LegacyConfigIssue } from "./types.js"; -export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { +function getPathValue(root: Record, path: string[]): unknown { + let cursor: unknown = root; + for (const key of path) { + if (!cursor || typeof cursor !== "object") { + return undefined; + } + cursor = (cursor as Record)[key]; + } + return cursor; +} + +export function findLegacyConfigIssues(raw: unknown, sourceRaw?: unknown): LegacyConfigIssue[] { if (!raw || typeof raw !== "object") { return []; } const root = raw as Record; + const sourceRoot = + sourceRaw && typeof sourceRaw === "object" ? (sourceRaw as Record) : root; const issues: LegacyConfigIssue[] = []; for (const rule of LEGACY_CONFIG_RULES) { - let cursor: unknown = root; - for (const key of rule.path) { - if (!cursor || typeof cursor !== "object") { - cursor = undefined; - break; - } - cursor = (cursor as Record)[key]; - } + const cursor = getPathValue(root, rule.path); if (cursor !== undefined && (!rule.match || rule.match(cursor, root))) { + if (rule.requireSourceLiteral) { + const sourceCursor = getPathValue(sourceRoot, rule.path); + if (sourceCursor === undefined) { + continue; + } + if (rule.match && !rule.match(sourceCursor, sourceRoot)) { + continue; + } + } issues.push({ path: rule.path.join("."), message: rule.message }); } }