From 6fc9d6f8e6ba4edce2a70cc1b4f1b8bc51405627 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 18 Jun 2026 18:09:08 +0800 Subject: [PATCH] fix(fs): preserve symlinks in atomicWrite and writeFileAtomicDurable When atomicWrite() or writeFileAtomicDurable() wrote to a path that was a symlink, the rename() call replaced the symlink itself with a regular file. This broke setups where config.toml was a symlink (e.g., to iCloud Drive or a dotfiles repo). Now both functions resolve symlinks via realpath() before creating the temp file and performing the rename, so the symlink remains intact and only the target file's content is updated. Fixes #751 --- packages/agent-core/src/utils/fs.ts | 35 ++++++-- packages/agent-core/test/utils/fs.test.ts | 98 +++++++++++++++++++++++ 2 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 packages/agent-core/test/utils/fs.test.ts diff --git a/packages/agent-core/src/utils/fs.ts b/packages/agent-core/src/utils/fs.ts index 7d4566aa1..317f00068 100644 --- a/packages/agent-core/src/utils/fs.ts +++ b/packages/agent-core/src/utils/fs.ts @@ -17,7 +17,7 @@ import { randomBytes } from 'node:crypto'; import { closeSync, fsyncSync, openSync } from 'node:fs'; import * as nodeFs from 'node:fs'; -import { open, rename, unlink } from 'node:fs/promises'; +import { lstat, open, realpath, rename, unlink } from 'node:fs/promises'; import { dirname } from 'pathe'; /** @@ -51,6 +51,23 @@ export function syncDirSync(dirPath: string): void { closeSync(fd); } } +/** + * Resolve a path that may be a symlink to the real underlying file path. + * If the path does not exist or is not a symlink, returns the original path. + */ +async function resolveSymlinkTarget(filePath: string): Promise { + try { + const stats = await lstat(filePath); + if (stats.isSymbolicLink()) { + return await realpath(filePath); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + } + return filePath; +} + /** * Write `content` to `filePath` atomically and durably: * 1. Write content to `.tmp`, fsync it, close it. @@ -67,7 +84,8 @@ export async function writeFileAtomicDurable( filePath: string, content: string | Uint8Array, ): Promise { - const tmpPath = filePath + '.tmp'; + const targetPath = await resolveSymlinkTarget(filePath); + const tmpPath = targetPath + '.tmp'; let renamed = false; try { const fh = await open(tmpPath, 'w'); @@ -80,15 +98,15 @@ export async function writeFileAtomicDurable( // Windows pre-unlink for MoveFileEx parity. if (process.platform === 'win32') { try { - await unlink(filePath); + await unlink(targetPath); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== 'ENOENT') throw error; } } - await rename(tmpPath, filePath); + await rename(tmpPath, targetPath); renamed = true; - await syncDir(dirname(filePath)); + await syncDir(dirname(targetPath)); } finally { if (!renamed) { // Best-effort cleanup of the `.tmp` file if we never got to the @@ -151,8 +169,9 @@ export async function atomicWrite( content: string | Uint8Array, _syncOverride?: (fd: number) => Promise, ): Promise { + const targetPath = await resolveSymlinkTarget(filePath); const hex = randomBytes(4).toString('hex'); - const tmpPath = `${filePath}.tmp.${process.pid}.${hex}`; + const tmpPath = `${targetPath}.tmp.${process.pid}.${hex}`; let renamed = false; try { const fh = await open(tmpPath, 'w'); @@ -167,13 +186,13 @@ export async function atomicWrite( // before the rename turns this into the POSIX-style "replace" case. if (process.platform === 'win32') { try { - await unlink(filePath); + await unlink(targetPath); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== 'ENOENT') throw error; } } - await rename(tmpPath, filePath); + await rename(tmpPath, targetPath); renamed = true; } finally { if (!renamed) { diff --git a/packages/agent-core/test/utils/fs.test.ts b/packages/agent-core/test/utils/fs.test.ts new file mode 100644 index 000000000..99cc3d782 --- /dev/null +++ b/packages/agent-core/test/utils/fs.test.ts @@ -0,0 +1,98 @@ +/** + * Tests for low-level fs utilities: atomicWrite, writeFileAtomicDurable. + */ + +import { lstat, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { atomicWrite, writeFileAtomicDurable } from '../../src/utils/fs'; + +let rootDir: string; + +beforeEach(async () => { + rootDir = join( + tmpdir(), + `kimi-fs-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(rootDir, { recursive: true }); +}); + +afterEach(async () => { + await rm(rootDir, { recursive: true, force: true }); +}); + +describe('atomicWrite', () => { + it('writes content to a new file', async () => { + const path = join(rootDir, 'new-file.txt'); + await atomicWrite(path, 'hello world'); + expect(await readFile(path, 'utf-8')).toBe('hello world'); + }); + + it('overwrites an existing file', async () => { + const path = join(rootDir, 'existing.txt'); + await writeFile(path, 'old', 'utf-8'); + await atomicWrite(path, 'new'); + expect(await readFile(path, 'utf-8')).toBe('new'); + }); + + it('preserves symlinks and updates the target', async () => { + const target = join(rootDir, 'real-config.toml'); + const link = join(rootDir, 'config.toml'); + + await writeFile(target, 'old-value', 'utf-8'); + await symlink(target, link); + + await atomicWrite(link, 'new-value'); + + // Symlink must still be a symlink + const stats = await lstat(link); + expect(stats.isSymbolicLink()).toBe(true); + + // Target must have new content + expect(await readFile(target, 'utf-8')).toBe('new-value'); + + // Reading through symlink must also give new content + expect(await readFile(link, 'utf-8')).toBe('new-value'); + }); + + it('preserves relative symlinks', async () => { + const subdir = join(rootDir, 'sub'); + await mkdir(subdir, { recursive: true }); + const target = join(subdir, 'target.json'); + const link = join(rootDir, 'link.json'); + + await writeFile(target, '{"old":true}', 'utf-8'); + await symlink('sub/target.json', link); + + await atomicWrite(link, '{"new":true}'); + + const stats = await lstat(link); + expect(stats.isSymbolicLink()).toBe(true); + expect(await readFile(target, 'utf-8')).toBe('{"new":true}'); + }); +}); + +describe('writeFileAtomicDurable', () => { + it('writes content to a new file', async () => { + const path = join(rootDir, 'durable.txt'); + await writeFileAtomicDurable(path, 'durable content'); + expect(await readFile(path, 'utf-8')).toBe('durable content'); + }); + + it('preserves symlinks and updates the target', async () => { + const target = join(rootDir, 'real-data.json'); + const link = join(rootDir, 'data.json'); + + await writeFile(target, '{"old":1}', 'utf-8'); + await symlink(target, link); + + await writeFileAtomicDurable(link, '{"new":2}'); + + const stats = await lstat(link); + expect(stats.isSymbolicLink()).toBe(true); + expect(await readFile(target, 'utf-8')).toBe('{"new":2}'); + }); +});