From 89c59c50d5d57772116ebe66423686ad486a39a1 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Wed, 11 Mar 2026 16:38:37 -0700 Subject: [PATCH] fix: persist migrated tlon install specs --- src/config/io.compat.test.ts | 58 ++++++++++++++++++++++++++++++++++++ src/config/io.ts | 58 ++++++++++++++++++++++++++---------- 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/src/config/io.compat.test.ts b/src/config/io.compat.test.ts index a66201018f4..5f4c8010482 100644 --- a/src/config/io.compat.test.ts +++ b/src/config/io.compat.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createConfigIO } from "./io.js"; +import { migrateLegacyConfig } from "./legacy-migrate.js"; async function withTempHome(run: (home: string) => Promise): Promise { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-")); @@ -212,4 +213,61 @@ describe("config io paths", () => { expect(snapshot.config.plugins?.installs?.tlon?.resolvedSpec).toBeUndefined(); }); }); + + it("persists migrated tlon install specs back to disk", 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); + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown; + const migrated = migrateLegacyConfig(parsed); + + expect(migrated.config).toBeTruthy(); + await io.writeConfigFile(migrated.config!); + + const written = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + plugins?: { + installs?: { + tlon?: { + spec?: string; + resolvedSpec?: string; + resolvedName?: string; + }; + }; + }; + }; + + expect(written.plugins?.installs?.tlon?.spec).toBe("@tloncorp/openclaw@2026.2.21"); + expect(written.plugins?.installs?.tlon?.resolvedSpec).toBeUndefined(); + expect(written.plugins?.installs?.tlon?.resolvedName).toBeUndefined(); + }); + }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 26625869a0e..8dbde896cd0 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -41,8 +41,7 @@ import { readConfigIncludeFileWithGuards, resolveConfigIncludes, } from "./includes.js"; -import { migrateLegacyConfig } from "./legacy-migrate.js"; -import { findLegacyConfigIssues } from "./legacy.js"; +import { applyLegacyMigrations, findLegacyConfigIssues } from "./legacy.js"; import { applyMergePatch } from "./merge-patch.js"; import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js"; import { normalizeConfigPaths } from "./normalize-paths.js"; @@ -719,18 +718,49 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { ) { const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw, sourceRaw); if (shouldAutoMigrateLegacyIssues(legacyIssues)) { - const migrated = migrateLegacyConfig(resolvedConfigRaw); - if (migrated.config) { - return { - legacyIssues, - validated: validateConfigObjectWithPlugins(migrated.config), - }; + const migrated = applyLegacyMigrations(resolvedConfigRaw); + if (migrated.next) { + const validatedMigrated = validateConfigObjectWithPlugins(migrated.next); + if (validatedMigrated.ok) { + return { + legacyIssues, + validated: validatedMigrated, + effectiveResolvedConfigRaw: migrated.next, + }; + } } } return { legacyIssues, validated: validateConfigObjectWithPlugins(resolvedConfigRaw), + effectiveResolvedConfigRaw: resolvedConfigRaw, + }; + } + + function validateResolvedRawConfigWithAutoMigration( + resolvedConfigRaw: unknown, + sourceRaw?: unknown, + ) { + const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw, sourceRaw); + if (shouldAutoMigrateLegacyIssues(legacyIssues)) { + const migrated = applyLegacyMigrations(resolvedConfigRaw); + if (migrated.next) { + const validatedMigrated = validateConfigObjectRawWithPlugins(migrated.next); + if (validatedMigrated.ok) { + return { + legacyIssues, + validated: validatedMigrated, + effectiveResolvedConfigRaw: migrated.next, + }; + } + } + } + + return { + legacyIssues, + validated: validateConfigObjectRawWithPlugins(resolvedConfigRaw), + effectiveResolvedConfigRaw: resolvedConfigRaw, }; } @@ -978,10 +1008,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { })); const resolvedConfigRaw = readResolution.resolvedConfigRaw; - const { legacyIssues, validated } = validateResolvedConfigWithAutoMigration( - resolvedConfigRaw, - parsedRes.parsed, - ); + const { legacyIssues, validated, effectiveResolvedConfigRaw } = + validateResolvedConfigWithAutoMigration(resolvedConfigRaw, parsedRes.parsed); if (!validated.ok) { return { snapshot: { @@ -989,7 +1017,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { exists: true, raw, parsed: parsedRes.parsed, - resolved: coerceConfig(resolvedConfigRaw), + resolved: coerceConfig(effectiveResolvedConfigRaw), valid: false, config: coerceConfig(resolvedConfigRaw), hash, @@ -1021,7 +1049,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { parsed: parsedRes.parsed, // Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults) // for config set/unset operations (issue #6070) - resolved: coerceConfig(resolvedConfigRaw), + resolved: coerceConfig(effectiveResolvedConfigRaw), valid: true, config: snapshotConfig, hash, @@ -1118,7 +1146,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } } - const validated = validateConfigObjectRawWithPlugins(persistCandidate); + const { validated } = validateResolvedRawConfigWithAutoMigration(persistCandidate); if (!validated.ok) { const issue = validated.issues[0]; const pathLabel = issue?.path ? issue.path : "";