fix(config): preserve authored tilde paths on write

This commit is contained in:
Peter Steinberger
2026-05-02 19:42:52 +01:00
parent 76c327c096
commit e18a383ff8
2 changed files with 106 additions and 1 deletions

View File

@@ -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<string, unknown> = { ...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);

View File

@@ -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");
});
});
});