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}'); + }); +});