From 6509e7e569dfe917ee88762c65ee4e63061b299d Mon Sep 17 00:00:00 2001 From: ktwu01 Date: Sun, 14 Jun 2026 15:08:22 -0500 Subject: [PATCH] fix: preserve config symlink writes --- .changeset/preserve-config-symlinks.md | 6 +++++ packages/agent-core/src/config/toml.ts | 25 ++++++++++++++++--- .../agent-core/test/config/configs.test.ts | 17 ++++++++++++- 3 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 .changeset/preserve-config-symlinks.md diff --git a/.changeset/preserve-config-symlinks.md b/.changeset/preserve-config-symlinks.md new file mode 100644 index 000000000..afa4c9003 --- /dev/null +++ b/.changeset/preserve-config-symlinks.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Preserve symlinked config files when saving configuration updates. diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 172e97cfc..e71487e01 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'node:fs'; -import { mkdir, open } from 'node:fs/promises'; -import { dirname } from 'pathe'; +import { lstat, mkdir, open, readlink } from 'node:fs/promises'; +import { dirname, resolve } from 'pathe'; import { ErrorCodes, KimiError } from '#/errors'; import { applyEnvModelConfig, stripEnvModelConfig } from './env-model'; @@ -450,7 +450,26 @@ export async function writeConfigFile(filePath: string, config: KimiConfig): Pro // stripEnvModelConfig / the getConfig -> setConfig round-trip). const validated = validateConfig(stripEnvModelConfig(config)); await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }); - await atomicWrite(filePath, `${stringifyToml(configToTomlData(validated))}\n`); + await atomicWrite( + await configWriteTarget(filePath), + `${stringifyToml(configToTomlData(validated))}\n`, + ); +} + +async function configWriteTarget(filePath: string): Promise { + let stat: Awaited>; + try { + stat = await lstat(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return filePath; + throw error; + } + if (!stat.isSymbolicLink()) return filePath; + // Preserve user-created config sync links while still atomically replacing + // the real target file. + const target = await readlink(filePath); + return resolve(dirname(filePath), target); } export function configToTomlData(config: KimiConfig): Record { diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 091eee384..6de35e08f 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync } from 'node:fs'; -import { readFile, rm, writeFile } from 'node:fs/promises'; +import { lstat, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; @@ -389,6 +389,21 @@ removed_flag = true expect(text).not.toContain('default_permission_mode'); }); + it('preserves config.toml symlinks when rewriting config files', async () => { + const dir = makeTempDir(); + const syncDir = makeTempDir(); + const configPath = join(dir, 'config.toml'); + const targetPath = join(syncDir, 'config.toml'); + await writeFile(targetPath, 'default_thinking = false\n', 'utf-8'); + await symlink(targetPath, configPath); + + const config = parseConfigString('default_thinking = true\n', configPath); + await writeConfigFile(configPath, config); + + expect((await lstat(configPath)).isSymbolicLink()).toBe(true); + await expect(readFile(targetPath, 'utf-8')).resolves.toContain('default_thinking = true'); + }); + it('rejects invalid TOML and invalid schema with KimiError(config.invalid)', () => { expectKimiErrorCode( () => parseConfigString('[[[', 'broken.toml'),