From 2bb9716598248d635d4724f7009575aa3f57ab89 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 21:27:37 +0100 Subject: [PATCH] fix: write clawdbot config atomically --- src/config/io.ts | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src/config/io.ts b/src/config/io.ts index f2b7c645d..0f514173a 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -295,13 +296,48 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } async function writeConfigFile(cfg: ClawdbotConfig) { - await deps.fs.promises.mkdir(path.dirname(configPath), { - recursive: true, - }); + const dir = path.dirname(configPath); + await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); const json = JSON.stringify(applyModelDefaults(cfg), null, 2) .trimEnd() .concat("\n"); - await deps.fs.promises.writeFile(configPath, json, "utf-8"); + + const tmp = path.join( + dir, + `${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`, + ); + + await deps.fs.promises.writeFile(tmp, json, { + encoding: "utf-8", + mode: 0o600, + }); + + await deps.fs.promises + .copyFile(configPath, `${configPath}.bak`) + .catch(() => { + // best-effort + }); + + try { + await deps.fs.promises.rename(tmp, configPath); + } catch (err) { + const code = (err as { code?: string }).code; + // Windows doesn't reliably support atomic replace via rename when dest exists. + if (code === "EPERM" || code === "EEXIST") { + await deps.fs.promises.copyFile(tmp, configPath); + await deps.fs.promises.chmod(configPath, 0o600).catch(() => { + // best-effort + }); + await deps.fs.promises.unlink(tmp).catch(() => { + // best-effort + }); + return; + } + await deps.fs.promises.unlink(tmp).catch(() => { + // best-effort + }); + throw err; + } } return {