mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 02:10:21 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user