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 <vincentkoc@ieee.org>
This commit is contained in:
Mark L
2026-03-02 11:53:00 +08:00
committed by GitHub
parent c9f0d6ac8e
commit 5b06c8c6e3
8 changed files with 202 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@@ -59,6 +59,10 @@ function hasOwnKey(target: Record<string, unknown>, 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<string, unknown>;
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",

View File

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

View File

@@ -2,6 +2,9 @@ export type LegacyConfigRule = {
path: string[];
message: string;
match?: (value: unknown, root: Record<string, unknown>) => 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 = {

View File

@@ -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<string, unknown>, path: string[]): unknown {
let cursor: unknown = root;
for (const key of path) {
if (!cursor || typeof cursor !== "object") {
return undefined;
}
cursor = (cursor as Record<string, unknown>)[key];
}
return cursor;
}
export function findLegacyConfigIssues(raw: unknown, sourceRaw?: unknown): LegacyConfigIssue[] {
if (!raw || typeof raw !== "object") {
return [];
}
const root = raw as Record<string, unknown>;
const sourceRoot =
sourceRaw && typeof sourceRaw === "object" ? (sourceRaw as Record<string, unknown>) : 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<string, unknown>)[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 });
}
}