From 3e2948a0b9cd7c37a7b88c25ac1209a7fb6709fa Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:03:35 +0000 Subject: [PATCH] fix(agent-core): write session metadata atomically --- .changeset/steady-session-metadata.md | 5 + packages/agent-core/src/session/index.ts | 35 ++++- .../src/session/store/session-store.ts | 11 +- packages/agent-core/src/utils/fs.ts | 27 ++-- .../test/harness/model-alias-session.test.ts | 14 +- packages/agent-core/test/session/init.test.ts | 38 +++++ .../agent-core/test/session/store.test.ts | 26 ++- packages/kaos/src/kaos.ts | 11 ++ packages/kaos/src/local.ts | 50 +++++- packages/kaos/src/ssh.ts | 130 ++++++++++++++- packages/kaos/test/local.test.ts | 29 +++- packages/kaos/test/ssh.test.ts | 148 +++++++++++++++++- 12 files changed, 497 insertions(+), 27 deletions(-) create mode 100644 .changeset/steady-session-metadata.md diff --git a/.changeset/steady-session-metadata.md b/.changeset/steady-session-metadata.md new file mode 100644 index 0000000000..a6f9eb9682 --- /dev/null +++ b/.changeset/steady-session-metadata.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Write session metadata atomically to avoid unreadable sessions after interrupted updates. diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 92f9cd291e..ba4e0c5adf 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -816,7 +816,11 @@ export class Session { const text = JSON.stringify(this.metadata, null, 2); const write = async () => { await this.persistenceKaos.mkdir(this.options.homedir, { parents: true, existOk: true }); - await this.persistenceKaos.writeText(this.metadataPath, text); + if (this.persistenceKaos.writeTextAtomic === undefined) { + await this.persistenceKaos.writeText(this.metadataPath, text); + return; + } + await this.persistenceKaos.writeTextAtomic(this.metadataPath, text); }; this.writeMetadataPromise = this.writeMetadataPromise.then(write, write); return this.writeMetadataPromise; @@ -824,7 +828,34 @@ export class Session { async readMetadata() { const text = await this.persistenceKaos.readText(this.metadataPath); - this.metadata = JSON.parse(text); + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch (error) { + throw new KimiError( + ErrorCodes.SESSION_STATE_INVALID, + 'Session state.json contains invalid JSON', + { cause: error }, + ); + } + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) || + !('agents' in parsed) || + typeof parsed.agents !== 'object' || + parsed.agents === null || + Array.isArray(parsed.agents) + ) { + throw new KimiError( + ErrorCodes.SESSION_STATE_INVALID, + 'Session state.json does not contain valid agent metadata', + ); + } + // SAFETY: legacy and v2 session files have different top-level metadata + // fields, but both guarantee an object-valued `agents` map. Individual + // agent fields are refined by the existing resume paths that consume them. + this.metadata = parsed as SessionMeta; return this.metadata; } diff --git a/packages/agent-core/src/session/store/session-store.ts b/packages/agent-core/src/session/store/session-store.ts index 630153b9b9..6fff4e6fd5 100644 --- a/packages/agent-core/src/session/store/session-store.ts +++ b/packages/agent-core/src/session/store/session-store.ts @@ -1,5 +1,5 @@ import type { Dirent } from 'node:fs'; -import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { cp, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises'; import * as nodePath from 'node:path'; import { dirname, isAbsolute, join, relative, resolve } from 'pathe'; @@ -24,6 +24,7 @@ import { type AgentRecord, type AgentRecordOf, } from '../../agent/records'; +import { atomicWrite } from '#/utils/fs'; const SessionSummaryStateSchema = z.object({ archived: z.boolean().optional(), @@ -215,7 +216,7 @@ export class SessionStore { title: normalized, isCustomTitle: true, }; - await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); + await atomicWrite(statePath, `${JSON.stringify(next, null, 2)}\n`); } async archive(id: string): Promise { @@ -238,7 +239,7 @@ export class SessionStore { archived: true, updatedAt: now, }; - await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); + await atomicWrite(statePath, `${JSON.stringify(next, null, 2)}\n`); return this.summaryFromDir(id, entry.sessionDir, entry.workDir); } @@ -510,7 +511,7 @@ export class SessionStore { agents: rewriteAgentHomedirs(parsed['agents'], sourceDir, targetDir), custom: forkCustomMetadata(parsed['custom'], input.metadata), }; - await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); + await atomicWrite(statePath, `${JSON.stringify(next, null, 2)}\n`); return next; } @@ -625,7 +626,7 @@ async function truncateForkedSessionAtTurn( lastPrompt: mainSlice.lastPrompt, agents: retainedAgents, }; - await writeFile(join(sessionDir, 'state.json'), `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); + await atomicWrite(join(sessionDir, 'state.json'), `${JSON.stringify(next, null, 2)}\n`); return next; } diff --git a/packages/agent-core/src/utils/fs.ts b/packages/agent-core/src/utils/fs.ts index 7d4566aa16..5c8631ab7c 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 { open, rename, stat, unlink } from 'node:fs/promises'; import { dirname } from 'pathe'; /** @@ -153,26 +153,27 @@ export async function atomicWrite( ): Promise { const hex = randomBytes(4).toString('hex'); const tmpPath = `${filePath}.tmp.${process.pid}.${hex}`; + let mode = 0o600; + try { + mode = (await stat(filePath)).mode & 0o777; + } catch (error) { + const fileError = error as NodeJS.ErrnoException; + if (fileError.code !== 'ENOENT') throw error; + } let renamed = false; try { - const fh = await open(tmpPath, 'w'); + const fh = await open(tmpPath, 'wx', 0o600); try { await fh.writeFile(content); + await fh.chmod(mode); await (_syncOverride ?? syncFd)(fh.fd); } finally { await fh.close(); } - // Windows `fs.rename` maps to MoveFileEx and fails with EPERM if - // the target is held by another handle. Pre-unlinking - // before the rename turns this into the POSIX-style "replace" case. - if (process.platform === 'win32') { - try { - await unlink(filePath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'ENOENT') throw error; - } - } + // Do not unlink the destination first. If replacement is unavailable + // (for example because Windows has an open handle), failing the rename + // preserves the previous complete file instead of creating a missing-file + // window. await rename(tmpPath, filePath); renamed = true; } finally { diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index 35d914a38b..d8617a0952 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -529,7 +529,19 @@ max_context_size = 1000000 expect(records).toContainEqual({ event: 'session_load_failed', sessionId: created.id, - properties: { reason: 'SyntaxError' }, + properties: { reason: 'session.state_invalid' }, + }); + }); + + it('resumeSession returns session.state_invalid when state.json contains malformed JSON', async () => { + const rpc = await createTestRpc(); + const created = await rpc.createSession({ workDir }); + await writeFile(join(created.sessionDir, 'state.json'), '{bad json', 'utf-8'); + + const freshRpc = await createTestRpc(); + + await expect(freshRpc.resumeSession({ sessionId: created.id })).rejects.toMatchObject({ + code: 'session.state_invalid', }); }); diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 1a5ab4b4d4..2814a7e27a 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -39,6 +39,44 @@ afterEach(async () => { }); describe('Session.init', () => { + it('writeMetadata uses atomic persistence when direct replacement is unavailable', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const local = testKaos.withCwd(workDir); + const persistenceKaos = new Proxy(local, { + get(target, property, receiver) { + if (property === 'writeText') { + return async () => { + throw new Error('direct replacement is unavailable'); + }; + } + if (property === 'writeTextAtomic') { + return async (path: string, data: string) => { + await writeFile(path, data, 'utf-8'); + return data.length; + }; + } + return Reflect.get(target, property, receiver); + }, + }); + const session = new Session({ + id: 'test-atomic-session-metadata', + kaos: local, + persistenceKaos, + homedir: sessionDir, + rpc: createSessionRpc([]), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + providerManager: testProviderManager(), + }); + + try { + await session.writeMetadata(); + await expect(access(join(sessionDir, 'state.json'))).resolves.toBeUndefined(); + } finally { + await session.close(); + } + }); + it('runs an isolated system-trigger turn and records the latest AGENTS as a system reminder', async () => { const workDir = await makeTempDir(); const sessionDir = await makeTempDir(); diff --git a/packages/agent-core/test/session/store.test.ts b/packages/agent-core/test/session/store.test.ts index cb5991e57a..bf903dbfe4 100644 --- a/packages/agent-core/test/session/store.test.ts +++ b/packages/agent-core/test/session/store.test.ts @@ -4,7 +4,7 @@ * Wiring: real temporary session storage; no stubbed collaborators. * Run: pnpm exec vitest run test/session/store.test.ts */ -import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -61,6 +61,30 @@ describe('SessionStore', () => { return wd; } + it.skipIf(process.platform === 'win32')( + 'rename restores state.json permissions masked by umask', + async () => { + const workDir = await trackWorkDir('rename-mode'); + const sessionDir = await seedSessionDir(homeDir, workDir, 'sess_rename_mode'); + await appendSessionIndexEntry(homeDir, { + sessionId: 'sess_rename_mode', + sessionDir, + workDir, + }); + const statePath = join(sessionDir, 'state.json'); + await chmod(statePath, 0o640); + const previousUmask = process.umask(0o077); + + try { + await store.rename('sess_rename_mode', 'Private session'); + } finally { + process.umask(previousUmask); + } + + expect((await stat(statePath)).mode & 0o777).toBe(0o640); + }, + ); + describe('create', () => { it('creates a session when an earlier index entry points to a missing directory', async () => { const workDir = await trackWorkDir('stale-create'); diff --git a/packages/kaos/src/kaos.ts b/packages/kaos/src/kaos.ts index e674418ceb..13c537ff99 100644 --- a/packages/kaos/src/kaos.ts +++ b/packages/kaos/src/kaos.ts @@ -85,6 +85,17 @@ export interface Kaos { data: string, options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding }, ): Promise; + /** + * Replace a text file without exposing partially written content. + * + * Implementations write to a temporary file in the target directory and + * atomically replace `path` when the backing filesystem supports it. + */ + writeTextAtomic?( + path: string, + data: string, + options?: { encoding?: BufferEncoding }, + ): Promise; /** Create a directory at `path`. */ mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise; diff --git a/packages/kaos/src/local.ts b/packages/kaos/src/local.ts index 92ff99b67a..a81aa159e4 100644 --- a/packages/kaos/src/local.ts +++ b/packages/kaos/src/local.ts @@ -1,5 +1,6 @@ import type { ChildProcess, SpawnOptions } from 'node:child_process'; import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import { appendFile, lstat, @@ -7,11 +8,13 @@ import { open, readdir, readFile, + rename, stat, + unlink, writeFile, } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { isAbsolute, join, normalize } from 'pathe'; +import { dirname, isAbsolute, join, normalize } from 'pathe'; import type { Readable, Writable } from 'node:stream'; import { detectEnvironmentFromNode, type Environment } from './environment'; @@ -669,6 +672,51 @@ export class LocalKaos implements Kaos { return data.length; } + /** Atomically replace a local text file using a same-directory temporary file. */ + async writeTextAtomic( + path: string, + data: string, + options?: { encoding?: BufferEncoding }, + ): Promise { + const resolved = this._resolvePath(path); + const encoding = options?.encoding ?? 'utf-8'; + const suffix = randomBytes(4).toString('hex'); + const tempPath = `${resolved}.tmp.${process.pid}.${suffix}`; + let mode = 0o600; + try { + mode = (await stat(resolved)).mode & 0o777; + } catch (error) { + const fileError = error as NodeJS.ErrnoException; + if (fileError.code !== 'ENOENT') throw error; + } + let replaced = false; + try { + const handle = await open(tempPath, 'wx', 0o600); + try { + await handle.writeFile(data, encoding); + await handle.chmod(mode); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(tempPath, resolved); + replaced = true; + if (process.platform !== 'win32') { + const directory = await open(dirname(resolved), 'r'); + try { + await directory.sync(); + } finally { + await directory.close(); + } + } + return data.length; + } finally { + if (!replaced) { + await unlink(tempPath).catch(() => {}); + } + } + } + async mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise { const resolved = this._resolvePath(path); const parents = options?.parents ?? false; diff --git a/packages/kaos/src/ssh.ts b/packages/kaos/src/ssh.ts index 3b897f9aa1..c0dcba3022 100644 --- a/packages/kaos/src/ssh.ts +++ b/packages/kaos/src/ssh.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { isAbsolute, join, normalize, resolve } from 'pathe'; import type { Readable, Writable } from 'node:stream'; @@ -391,14 +392,36 @@ function sftpReadFile(sftp: SFTPWrapper, path: string): Promise { }); } -function sftpWriteFile(sftp: SFTPWrapper, path: string, data: string | Buffer): Promise { +function sftpWriteFile( + sftp: SFTPWrapper, + path: string, + data: string | Buffer, + mode?: number, +): Promise { return new Promise((resolve, reject) => { - sftp.writeFile(path, data, (err) => { + const callback = (err: Error | null | undefined) => { if (err) { reject(mapSftpError('writeFile', err)); } else { resolve(); } + }; + if (mode === undefined) { + sftp.writeFile(path, data, callback); + } else { + sftp.writeFile(path, data, { mode }, callback); + } + }); +} + +function sftpChmod(sftp: SFTPWrapper, path: string, mode: number): Promise { + return new Promise((resolve, reject) => { + sftp.chmod(path, mode, (err) => { + if (err) { + reject(mapSftpError('chmod', err)); + } else { + resolve(); + } }); }); } @@ -415,6 +438,55 @@ function sftpAppendFile(sftp: SFTPWrapper, path: string, data: string | Buffer): }); } +function sftpUnlink(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.unlink(path, (err) => { + if (err) { + reject(mapSftpError('unlink', err)); + } else { + resolve(); + } + }); + }); +} + +function sftpRename(sftp: SFTPWrapper, source: string, target: string): Promise { + return new Promise((resolve, reject) => { + sftp.rename(source, target, (err) => { + if (err) { + reject(mapSftpError('rename', err)); + } else { + resolve(); + } + }); + }); +} + +function sftpPosixRename(sftp: SFTPWrapper, source: string, target: string): Promise { + return new Promise((resolve, reject) => { + try { + sftp.ext_openssh_rename(source, target, (err) => { + if (err) { + reject(mapSftpError('posix-rename@openssh.com', err)); + } else { + resolve(); + } + }); + } catch (error) { + reject(mapSftpError('posix-rename@openssh.com', error)); + } + }); +} + +function isUnsupportedSftpExtension(error: unknown): boolean { + if (!(error instanceof KaosSSHError)) return false; + const statusCode = getSftpStatusCode(); + return ( + error.code === statusCode.OP_UNSUPPORTED || + error.message.endsWith('Server does not support this extended request') + ); +} + function clientExec(client: Client, command: string): Promise { return new Promise((resolve, reject) => { client.exec(command, (err: Error | undefined, channel: ClientChannel) => { @@ -766,6 +838,60 @@ export class SSHKaos implements Kaos { return data.length; } + /** + * Atomically replace a remote text file. + * + * Existing targets prefer the OpenSSH POSIX rename extension. Servers + * without it fall back to the remote POSIX `mv` command, which uses the + * same-filesystem rename operation for this same-directory temporary file. + */ + async writeTextAtomic( + path: string, + data: string, + options?: { encoding?: BufferEncoding }, + ): Promise { + const resolved = this._resolvePath(path); + const encoding = options?.encoding ?? 'utf-8'; + const suffix = randomBytes(4).toString('hex'); + const tempPath = `${resolved}.tmp.${process.pid}.${suffix}`; + const targetExists = await sftpExists(this._sftp, resolved); + const mode = targetExists ? (await sftpStat(this._sftp, resolved)).mode & 0o777 : 0o600; + let replaced = false; + try { + const content = Buffer.from(data, encoding); + await sftpWriteFile(this._sftp, tempPath, content, 0o600); + await sftpChmod(this._sftp, tempPath, mode); + if (targetExists) { + try { + await sftpPosixRename(this._sftp, tempPath, resolved); + } catch (error) { + if (!isUnsupportedSftpExtension(error)) throw error; + await this._replaceViaRemoteMove(tempPath, resolved); + } + } else { + await sftpRename(this._sftp, tempPath, resolved); + } + replaced = true; + return data.length; + } finally { + if (!replaced) { + await sftpUnlink(this._sftp, tempPath).catch(() => {}); + } + } + } + + private async _replaceViaRemoteMove(source: string, target: string): Promise { + const process = await this.exec('mv', '-f', source, target); + try { + const exitCode = await process.wait(); + if (exitCode !== 0) { + throw new KaosSSHError(`mv failed with exit code ${exitCode}`); + } + } finally { + await process.dispose(); + } + } + async mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise { const resolved = this._resolvePath(path); const parents = options?.parents ?? false; diff --git a/packages/kaos/test/local.test.ts b/packages/kaos/test/local.test.ts index acaa13ba11..8ec8a8bfe0 100644 --- a/packages/kaos/test/local.test.ts +++ b/packages/kaos/test/local.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -180,6 +180,33 @@ describe('LocalKaos', () => { } expect(lines.join('')).toBe('line1\nline2'); }); + + it('writeTextAtomic replaces an existing file with complete text', async () => { + const filePath = join(tempDir, 'state.json'); + await kaos.writeText(filePath, '{"title":"before"}'); + + await kaos.writeTextAtomic(filePath, '{"title":"after"}'); + + await expect(kaos.readText(filePath)).resolves.toBe('{"title":"after"}'); + }); + + it.skipIf(process.platform === 'win32')( + 'writeTextAtomic restores file permissions masked by umask', + async () => { + const filePath = join(tempDir, 'private-state.json'); + await kaos.writeText(filePath, '{}'); + await chmod(filePath, 0o640); + const previousUmask = process.umask(0o077); + + try { + await kaos.writeTextAtomic(filePath, '{"title":"private"}'); + } finally { + process.umask(previousUmask); + } + + expect((await stat(filePath)).mode & 0o777).toBe(0o640); + }, + ); }); describe('readLines streaming', () => { diff --git a/packages/kaos/test/ssh.test.ts b/packages/kaos/test/ssh.test.ts index e506e3f1f7..aa0b991ffd 100644 --- a/packages/kaos/test/ssh.test.ts +++ b/packages/kaos/test/ssh.test.ts @@ -519,15 +519,101 @@ describe('SSHKaos SFTP error mapping', () => { }; } - function makeFakeKaos(sftp: unknown): SSHKaos { + function makeFakeKaos( + sftp: unknown, + remoteMove?: (source: string, target: string) => number, + ): SSHKaos { const instance = Object.create(SSHKaos.prototype) as SSHKaos; const internal = instance as unknown as { _sftp: unknown; _cwd: string; _home: string }; internal._sftp = sftp; internal._cwd = '/'; internal._home = '/'; + if (remoteMove !== undefined) { + instance.exec = async (...args: string[]) => + ({ + wait: async () => remoteMove(args[2]!, args[3]!), + dispose: () => {}, + }) as Awaited>; + } return instance; } + function makeAtomicSftp( + entries: ReadonlyArray, + replacementError?: Error, + ) { + const files = new Map(entries.map(([path, content]) => [path, Buffer.from(content)])); + const modes = new Map(entries.map(([path]) => [path, 0o600])); + const moveNow = (source: string, target: string): void => { + const content = files.get(source); + const mode = modes.get(source); + if (content !== undefined) files.set(target, content); + if (mode !== undefined) modes.set(target, mode); + files.delete(source); + modes.delete(source); + }; + const move = ( + source: string, + target: string, + cb: (err: Error | null) => void, + ): void => { + moveNow(source, target); + cb(null); + }; + return { + moveNow, + modes, + sftp: { + writeFile( + path: string, + data: Buffer, + optionsOrCallback: { mode?: number } | ((err: Error | null) => void), + callback?: (err: Error | null) => void, + ): void { + const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback; + const done = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; + files.set(path, Buffer.from(data)); + modes.set(path, (options?.mode ?? modes.get(path) ?? 0o666) & 0o700); + done?.(null); + }, + chmod(path: string, mode: number, cb: (err: Error | null) => void): void { + modes.set(path, mode); + cb(null); + }, + exists(path: string, cb: (exists: boolean) => void): void { + cb(files.has(path)); + }, + stat(path: string, cb: (err: Error | null, stats?: { mode: number }) => void): void { + cb(null, { mode: modes.get(path) ?? 0o600 }); + }, + rename: move, + ext_openssh_rename( + source: string, + target: string, + cb: (err: Error | null) => void, + ): void { + if (replacementError !== undefined) { + if (isUnsupportedExtensionError(replacementError)) throw replacementError; + cb(replacementError); + return; + } + move(source, target, cb); + }, + unlink(path: string, cb: (err: Error | null) => void): void { + files.delete(path); + cb(null); + }, + readFile(path: string, cb: (err: Error | null, data?: Buffer) => void): void { + cb(null, files.get(path)); + }, + }, + }; + } + + function isUnsupportedExtensionError(error: Error): boolean { + return error.message === 'Server does not support this extended request'; + } + // ── stat(): the one method that already wraps errors. ──────────────── it('stat() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { @@ -646,6 +732,66 @@ describe('SSHKaos SFTP error mapping', () => { ); }); + it('writeTextAtomic preserves the existing file when replacement fails', async () => { + const { sftp } = makeAtomicSftp([['/state.json', 'before']], makeSftpError(4)); + const kaos = makeFakeKaos(sftp); + + await expect(kaos.writeTextAtomic('/state.json', 'after')).rejects.toBeInstanceOf(KaosSSHError); + + await expect(kaos.readText('/state.json')).resolves.toBe('before'); + }); + + it('writeTextAtomic replaces an existing file through POSIX rename', async () => { + const { sftp } = makeAtomicSftp([['/state.json', 'before']]); + const kaos = makeFakeKaos(sftp); + + await kaos.writeTextAtomic('/state.json', 'after'); + + await expect(kaos.readText('/state.json')).resolves.toBe('after'); + }); + + it('writeTextAtomic uses remote mv when POSIX rename is unsupported', async () => { + const unsupported = new Error('Server does not support this extended request'); + const { moveNow, sftp } = makeAtomicSftp([['/state.json', 'before']], unsupported); + const kaos = makeFakeKaos(sftp, (source, target) => { + moveNow(source, target); + return 0; + }); + + await kaos.writeTextAtomic('/state.json', 'after'); + + await expect(kaos.readText('/state.json')).resolves.toBe('after'); + }); + + it('writeTextAtomic preserves the target when remote mv fails', async () => { + const unsupported = new Error('Server does not support this extended request'); + const { sftp } = makeAtomicSftp([['/state.json', 'before']], unsupported); + const kaos = makeFakeKaos(sftp, () => 1); + + await expect(kaos.writeTextAtomic('/state.json', 'after')).rejects.toBeInstanceOf(KaosSSHError); + + await expect(kaos.readText('/state.json')).resolves.toBe('before'); + }); + + it('writeTextAtomic preserves existing remote file permissions', async () => { + const { modes, sftp } = makeAtomicSftp([['/state.json', 'before']]); + modes.set('/state.json', 0o640); + const kaos = makeFakeKaos(sftp); + + await kaos.writeTextAtomic('/state.json', 'after'); + + expect(modes.get('/state.json')).toBe(0o640); + }); + + it('writeTextAtomic creates a new file through standard rename', async () => { + const { sftp } = makeAtomicSftp([]); + const kaos = makeFakeKaos(sftp); + + await kaos.writeTextAtomic('/state.json', 'created'); + + await expect(kaos.readText('/state.json')).resolves.toBe('created'); + }); + it('writeBytes() maps PERMISSION_DENIED → KaosPermissionError', async () => { const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { writeFile: true })); await expect(kaos.writeBytes('/forbidden', Buffer.from('x'))).rejects.toBeInstanceOf(