mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 17:20:21 +00:00
fix: auto-migrate legacy tlon config on load
This commit is contained in:
@@ -360,7 +360,7 @@ describe("config strict validation", () => {
|
|||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags legacy config entries without auto-migrating", async () => {
|
it("keeps auto-migrated legacy config entries valid in snapshots", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
await writeOpenClawConfig(home, {
|
await writeOpenClawConfig(home, {
|
||||||
agents: { list: [{ id: "pi" }] },
|
agents: { list: [{ id: "pi" }] },
|
||||||
@@ -368,9 +368,12 @@ describe("config strict validation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const snap = await readConfigFileSnapshot();
|
const snap = await readConfigFileSnapshot();
|
||||||
|
const snapshotConfig = snap.config as { routing?: unknown };
|
||||||
|
|
||||||
expect(snap.valid).toBe(false);
|
expect(snap.valid).toBe(true);
|
||||||
expect(snap.legacyIssues).not.toHaveLength(0);
|
expect(snap.legacyIssues).not.toHaveLength(0);
|
||||||
|
expect(snapshotConfig.routing).toBeUndefined();
|
||||||
|
expect(snap.config.channels?.whatsapp?.allowFrom).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -404,8 +407,9 @@ describe("config strict validation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const snap = await readConfigFileSnapshot();
|
const snap = await readConfigFileSnapshot();
|
||||||
expect(snap.valid).toBe(false);
|
expect(snap.valid).toBe(true);
|
||||||
expect(snap.legacyIssues.some((issue) => issue.path === "gateway.bind")).toBe(true);
|
expect(snap.legacyIssues.some((issue) => issue.path === "gateway.bind")).toBe(true);
|
||||||
|
expect(snap.config.gateway?.bind).toBe("lan");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,8 +68,11 @@ function expectRoutingAllowFromLegacySnapshot(
|
|||||||
ctx: { snapshot: ConfigSnapshot; parsed: unknown },
|
ctx: { snapshot: ConfigSnapshot; parsed: unknown },
|
||||||
expectedAllowFrom: string[],
|
expectedAllowFrom: string[],
|
||||||
) {
|
) {
|
||||||
expect(ctx.snapshot.valid).toBe(false);
|
const snapshotConfig = ctx.snapshot.config as { routing?: unknown };
|
||||||
|
expect(ctx.snapshot.valid).toBe(true);
|
||||||
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "routing.allowFrom")).toBe(true);
|
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "routing.allowFrom")).toBe(true);
|
||||||
|
expect(snapshotConfig.routing).toBeUndefined();
|
||||||
|
expect(ctx.snapshot.config.channels?.whatsapp?.allowFrom).toBeUndefined();
|
||||||
const parsed = ctx.parsed as {
|
const parsed = ctx.parsed as {
|
||||||
routing?: { allowFrom?: string[] };
|
routing?: { allowFrom?: string[] };
|
||||||
channels?: unknown;
|
channels?: unknown;
|
||||||
@@ -269,8 +272,12 @@ describe("legacy config detection", () => {
|
|||||||
await withSnapshotForConfig(
|
await withSnapshotForConfig(
|
||||||
{ memorySearch: { provider: "local", fallback: "none" } },
|
{ memorySearch: { provider: "local", fallback: "none" } },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
expect(ctx.snapshot.valid).toBe(false);
|
expect(ctx.snapshot.valid).toBe(true);
|
||||||
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe(true);
|
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe(true);
|
||||||
|
expect(ctx.snapshot.config.agents?.defaults?.memorySearch).toEqual({
|
||||||
|
provider: "local",
|
||||||
|
fallback: "none",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -285,8 +292,9 @@ describe("legacy config detection", () => {
|
|||||||
});
|
});
|
||||||
it("flags legacy provider sections in snapshot", async () => {
|
it("flags legacy provider sections in snapshot", async () => {
|
||||||
await withSnapshotForConfig({ whatsapp: { allowFrom: ["+1555"] } }, async (ctx) => {
|
await withSnapshotForConfig({ whatsapp: { allowFrom: ["+1555"] } }, async (ctx) => {
|
||||||
expect(ctx.snapshot.valid).toBe(false);
|
expect(ctx.snapshot.valid).toBe(true);
|
||||||
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "whatsapp")).toBe(true);
|
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "whatsapp")).toBe(true);
|
||||||
|
expect(ctx.snapshot.config.channels?.whatsapp?.allowFrom).toEqual(["+1555"]);
|
||||||
|
|
||||||
const parsed = ctx.parsed as {
|
const parsed = ctx.parsed as {
|
||||||
channels?: unknown;
|
channels?: unknown;
|
||||||
|
|||||||
@@ -166,4 +166,50 @@ describe("config io paths", () => {
|
|||||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("- gateway.port:"));
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("- gateway.port:"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-migrates legacy tlon install specs during load and snapshot reads", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const configDir = path.join(home, ".openclaw");
|
||||||
|
await fs.mkdir(configDir, { recursive: true });
|
||||||
|
const configPath = path.join(configDir, "openclaw.json");
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
installs: {
|
||||||
|
tlon: {
|
||||||
|
source: "npm",
|
||||||
|
spec: "@openclaw/tlon@2026.2.21",
|
||||||
|
resolvedName: "@openclaw/tlon",
|
||||||
|
resolvedVersion: "2026.2.21",
|
||||||
|
resolvedSpec: "@openclaw/tlon@2026.2.21",
|
||||||
|
integrity: "sha512-old",
|
||||||
|
shasum: "old",
|
||||||
|
resolvedAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
installPath: "/tmp/tlon",
|
||||||
|
version: "2026.2.21",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const io = createIoForHome(home);
|
||||||
|
expect(io.loadConfig().plugins?.installs?.tlon?.spec).toBe("@tloncorp/openclaw@2026.2.21");
|
||||||
|
expect(io.loadConfig().plugins?.installs?.tlon?.resolvedSpec).toBeUndefined();
|
||||||
|
|
||||||
|
const snapshot = await io.readConfigFileSnapshot();
|
||||||
|
expect(snapshot.valid).toBe(true);
|
||||||
|
expect(
|
||||||
|
snapshot.legacyIssues.some((issue) => issue.path === "plugins.installs.tlon.spec"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(snapshot.config.plugins?.installs?.tlon?.spec).toBe("@tloncorp/openclaw@2026.2.21");
|
||||||
|
expect(snapshot.config.plugins?.installs?.tlon?.resolvedSpec).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
readConfigIncludeFileWithGuards,
|
readConfigIncludeFileWithGuards,
|
||||||
resolveConfigIncludes,
|
resolveConfigIncludes,
|
||||||
} from "./includes.js";
|
} from "./includes.js";
|
||||||
|
import { migrateLegacyConfig } from "./legacy-migrate.js";
|
||||||
import { findLegacyConfigIssues } from "./legacy.js";
|
import { findLegacyConfigIssues } from "./legacy.js";
|
||||||
import { applyMergePatch } from "./merge-patch.js";
|
import { applyMergePatch } from "./merge-patch.js";
|
||||||
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
|
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
|
||||||
@@ -197,6 +198,13 @@ function hasOwnObjectKey(value: Record<string, unknown>, key: string): boolean {
|
|||||||
return Object.prototype.hasOwnProperty.call(value, key);
|
return Object.prototype.hasOwnProperty.call(value, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldAutoMigrateLegacyIssues(legacyIssues: LegacyConfigIssue[]): boolean {
|
||||||
|
return (
|
||||||
|
legacyIssues.length > 0 &&
|
||||||
|
legacyIssues.every((issue) => issue.message.includes("(auto-migrated on load)."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const WRITE_PRUNED_OBJECT = Symbol("write-pruned-object");
|
const WRITE_PRUNED_OBJECT = Symbol("write-pruned-object");
|
||||||
|
|
||||||
type UnsetPathWriteResult = {
|
type UnsetPathWriteResult = {
|
||||||
@@ -705,6 +713,27 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
const configPath =
|
const configPath =
|
||||||
candidatePaths.find((candidate) => deps.fs.existsSync(candidate)) ?? requestedConfigPath;
|
candidatePaths.find((candidate) => deps.fs.existsSync(candidate)) ?? requestedConfigPath;
|
||||||
|
|
||||||
|
function validateResolvedConfigWithAutoMigration(
|
||||||
|
resolvedConfigRaw: unknown,
|
||||||
|
sourceRaw?: unknown,
|
||||||
|
) {
|
||||||
|
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw, sourceRaw);
|
||||||
|
if (shouldAutoMigrateLegacyIssues(legacyIssues)) {
|
||||||
|
const migrated = migrateLegacyConfig(resolvedConfigRaw);
|
||||||
|
if (migrated.config) {
|
||||||
|
return {
|
||||||
|
legacyIssues,
|
||||||
|
validated: validateConfigObjectWithPlugins(migrated.config),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
legacyIssues,
|
||||||
|
validated: validateConfigObjectWithPlugins(resolvedConfigRaw),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function loadConfig(): OpenClawConfig {
|
function loadConfig(): OpenClawConfig {
|
||||||
try {
|
try {
|
||||||
maybeLoadDotEnvForConfig(deps.env);
|
maybeLoadDotEnvForConfig(deps.env);
|
||||||
@@ -743,7 +772,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
if (preValidationDuplicates.length > 0) {
|
if (preValidationDuplicates.length > 0) {
|
||||||
throw new DuplicateAgentDirError(preValidationDuplicates);
|
throw new DuplicateAgentDirError(preValidationDuplicates);
|
||||||
}
|
}
|
||||||
const validated = validateConfigObjectWithPlugins(resolvedConfig);
|
const { validated } = validateResolvedConfigWithAutoMigration(resolvedConfig, parsed);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
const details = validated.issues
|
const details = validated.issues
|
||||||
.map(
|
.map(
|
||||||
@@ -949,11 +978,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const resolvedConfigRaw = readResolution.resolvedConfigRaw;
|
const resolvedConfigRaw = readResolution.resolvedConfigRaw;
|
||||||
// Detect legacy keys on resolved config, but only mark source-literal legacy
|
const { legacyIssues, validated } = validateResolvedConfigWithAutoMigration(
|
||||||
// entries (for auto-migration) when they are present in the parsed source.
|
resolvedConfigRaw,
|
||||||
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw, parsedRes.parsed);
|
parsedRes.parsed,
|
||||||
|
);
|
||||||
const validated = validateConfigObjectWithPlugins(resolvedConfigRaw);
|
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return {
|
return {
|
||||||
snapshot: {
|
snapshot: {
|
||||||
|
|||||||
Reference in New Issue
Block a user