From e244bf893bfa262fba4b43fdfba2ee24393eeffc Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Wed, 11 Mar 2026 16:33:18 -0700 Subject: [PATCH] fix: auto-migrate legacy tlon config on load --- src/config/config-misc.test.ts | 10 ++-- ...etection.accepts-imessage-dmpolicy.test.ts | 14 ++++-- src/config/io.compat.test.ts | 46 +++++++++++++++++++ src/config/io.ts | 40 +++++++++++++--- 4 files changed, 98 insertions(+), 12 deletions(-) diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 647986a96e0..07bbf9c0e48 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -360,7 +360,7 @@ describe("config strict validation", () => { 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 writeOpenClawConfig(home, { agents: { list: [{ id: "pi" }] }, @@ -368,9 +368,12 @@ describe("config strict validation", () => { }); 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(snapshotConfig.routing).toBeUndefined(); + expect(snap.config.channels?.whatsapp?.allowFrom).toBeUndefined(); }); }); @@ -404,8 +407,9 @@ describe("config strict validation", () => { }); 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.config.gateway?.bind).toBe("lan"); }); }); }); diff --git a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts index 89632bbc543..d6d73560a8e 100644 --- a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts +++ b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts @@ -68,8 +68,11 @@ function expectRoutingAllowFromLegacySnapshot( ctx: { snapshot: ConfigSnapshot; parsed: unknown }, 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(snapshotConfig.routing).toBeUndefined(); + expect(ctx.snapshot.config.channels?.whatsapp?.allowFrom).toBeUndefined(); const parsed = ctx.parsed as { routing?: { allowFrom?: string[] }; channels?: unknown; @@ -269,8 +272,12 @@ describe("legacy config detection", () => { await withSnapshotForConfig( { memorySearch: { provider: "local", fallback: "none" } }, 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.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 () => { 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.config.channels?.whatsapp?.allowFrom).toEqual(["+1555"]); const parsed = ctx.parsed as { channels?: unknown; diff --git a/src/config/io.compat.test.ts b/src/config/io.compat.test.ts index 7c357c63c68..a66201018f4 100644 --- a/src/config/io.compat.test.ts +++ b/src/config/io.compat.test.ts @@ -166,4 +166,50 @@ describe("config io paths", () => { 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(); + }); + }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 2b542bba755..26625869a0e 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -41,6 +41,7 @@ import { readConfigIncludeFileWithGuards, resolveConfigIncludes, } from "./includes.js"; +import { migrateLegacyConfig } from "./legacy-migrate.js"; import { findLegacyConfigIssues } from "./legacy.js"; import { applyMergePatch } from "./merge-patch.js"; import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js"; @@ -197,6 +198,13 @@ function hasOwnObjectKey(value: Record, key: string): boolean { 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"); type UnsetPathWriteResult = { @@ -705,6 +713,27 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const configPath = 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 { try { maybeLoadDotEnvForConfig(deps.env); @@ -743,7 +772,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { if (preValidationDuplicates.length > 0) { throw new DuplicateAgentDirError(preValidationDuplicates); } - const validated = validateConfigObjectWithPlugins(resolvedConfig); + const { validated } = validateResolvedConfigWithAutoMigration(resolvedConfig, parsed); if (!validated.ok) { const details = validated.issues .map( @@ -949,11 +978,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { })); const resolvedConfigRaw = readResolution.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); + const { legacyIssues, validated } = validateResolvedConfigWithAutoMigration( + resolvedConfigRaw, + parsedRes.parsed, + ); if (!validated.ok) { return { snapshot: {