From e18a383ff87405fade29b6cb5cdeb81ce5dc9c33 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 19:42:52 +0100 Subject: [PATCH] fix(config): preserve authored tilde paths on write --- src/config/io.ts | 73 +++++++++++++++++++++++++++++- src/config/io.write-config.test.ts | 34 ++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/config/io.ts b/src/config/io.ts index c88b7bec079..0e9e7372e39 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1089,6 +1089,71 @@ type ConfigReadResolution = { envWarnings: EnvSubstitutionWarning[]; }; +const TILDE_PATH_VALUE_RE = /^~(?=$|[\\/])/; +const PATH_LIKE_CONFIG_KEY_RE = /(dir|path|paths|file|root|workspace)$/i; +const PATH_LIKE_CONFIG_LIST_KEYS = new Set(["paths", "pathPrepend"]); + +function isPathLikeConfigKey(key: string | undefined): boolean { + return Boolean(key && (PATH_LIKE_CONFIG_KEY_RE.test(key) || PATH_LIKE_CONFIG_LIST_KEYS.has(key))); +} + +function expandAuthoredTildePath(value: string, home: string): string { + const suffix = value.slice(1); + if (!suffix) { + return home; + } + if (suffix.startsWith("/") || suffix.startsWith("\\")) { + return path.join(home, suffix.slice(1)); + } + return value; +} + +function restoreAuthoredTildePathsForWrite( + next: unknown, + authored: unknown, + key: string | undefined, + home: string, +): unknown { + if ( + typeof next === "string" && + typeof authored === "string" && + isPathLikeConfigKey(key) && + TILDE_PATH_VALUE_RE.test(authored.trim()) && + path.normalize(next) === path.normalize(expandAuthoredTildePath(authored.trim(), home)) + ) { + return authored; + } + + if (Array.isArray(next) && Array.isArray(authored)) { + const normalizeChildren = isPathLikeConfigKey(key); + return next.map((entry, index) => + restoreAuthoredTildePathsForWrite( + entry, + authored[index], + normalizeChildren ? key : undefined, + home, + ), + ); + } + + if (!isRecord(next) || !isRecord(authored)) { + return next; + } + + const out: Record = { ...next }; + for (const [childKey, childValue] of Object.entries(out)) { + if (Object.prototype.hasOwnProperty.call(authored, childKey)) { + out[childKey] = restoreAuthoredTildePathsForWrite( + childValue, + authored[childKey], + childKey, + home, + ); + } + } + return out; +} + function resolveConfigIncludesForRead( parsed: unknown, configPath: string, @@ -2069,7 +2134,13 @@ export function createConfigIO( envRefMap && changedPaths ? (restoreEnvRefsFromMap(cfgToWrite, "", envRefMap, changedPaths) as OpenClawConfig) : cfgToWrite; - const outputConfig = applyUnsetPathsForWrite(outputConfigBase, unsetPaths); + const tildeRestoredOutputConfig = restoreAuthoredTildePathsForWrite( + outputConfigBase, + snapshot.parsed, + undefined, + deps.homedir(), + ) as OpenClawConfig; + const outputConfig = applyUnsetPathsForWrite(tildeRestoredOutputConfig, unsetPaths); // Do NOT apply runtime defaults when writing - user config should only contain // explicitly set values. Runtime defaults are applied when loading (issue #6070). const stampedOutputConfig = stampConfigVersion(outputConfig); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index c7ea4e1d526..e50c46159b4 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -1182,4 +1182,38 @@ describe("config io write", () => { } }); }); + + it("preserves authored tilde paths when runtime-shaped writes hand back absolute paths", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + logging: { file: "~/openclaw-upgrade-survivor/gateway.jsonl" }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const io = createFastConfigIO(home); + const snapshot = await io.readConfigFileSnapshot(); + + await io.writeConfigFile( + { + logging: { + file: path.join(home, "openclaw-upgrade-survivor", "gateway.jsonl"), + level: "debug", + }, + }, + { baseSnapshot: snapshot }, + ); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as OpenClawConfig; + expect(persisted.logging?.file).toBe("~/openclaw-upgrade-survivor/gateway.jsonl"); + expect(persisted.logging?.level).toBe("debug"); + }); + }); });