From 18f8393b6c6791769318349033d4760a9fe745a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 16:44:46 +0000 Subject: [PATCH] fix: harden sandbox writes and centralize atomic file writes --- src/agents/sandbox/fs-bridge.test.ts | 34 ++++++++++ src/agents/sandbox/fs-bridge.ts | 74 +++++++++++++++++++-- src/agents/sandbox/registry.ts | 24 +------ src/config/sessions/store.ts | 71 ++++++-------------- src/hooks/bundled/session-memory/handler.ts | 10 ++- src/infra/json-files.ts | 48 +++++++++---- src/infra/update-startup.ts | 4 +- src/memory/qmd-manager.test.ts | 29 +++----- src/memory/qmd-manager.ts | 11 ++- src/node-host/config.ts | 10 +-- src/plugin-sdk/json-store.ts | 14 ++-- src/telegram/update-offset-store.ts | 13 ++-- 12 files changed, 203 insertions(+), 139 deletions(-) diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index 85ed9236883..4a9243310df 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -89,6 +89,9 @@ function installDockerReadMock(params?: { canonicalPath?: string }) { if (script.includes('cat -- "$1"')) { return dockerExecResult("content"); } + if (script.includes("mktemp")) { + return dockerExecResult("/workspace/.openclaw-write-b.txt.ABC123\n"); + } return dockerExecResult(""); }); } @@ -200,6 +203,37 @@ describe("sandbox fs bridge shell compatibility", () => { expect(mockedExecDockerRaw).not.toHaveBeenCalled(); }); + it("writes via temp file + atomic rename (never direct truncation)", async () => { + const bridge = createSandboxFsBridge({ sandbox: createSandbox() }); + + await bridge.writeFile({ filePath: "b.txt", data: "hello" }); + + const scripts = getScriptsFromCalls(); + expect(scripts.some((script) => script.includes('cat >"$1"'))).toBe(false); + expect(scripts.some((script) => script.includes('cat >"$tmp"'))).toBe(true); + expect(scripts.some((script) => script.includes('mv -f -- "$1" "$2"'))).toBe(true); + }); + + it("re-validates target before final rename and cleans temp file on failure", async () => { + mockedOpenBoundaryFile + .mockImplementationOnce(async () => ({ ok: false, reason: "path" })) + .mockImplementationOnce(async () => ({ + ok: false, + reason: "validation", + error: new Error("Hardlinked path is not allowed"), + })); + + const bridge = createSandboxFsBridge({ sandbox: createSandbox() }); + await expect(bridge.writeFile({ filePath: "b.txt", data: "hello" })).rejects.toThrow( + /hardlinked path/i, + ); + + const scripts = getScriptsFromCalls(); + expect(scripts.some((script) => script.includes("mktemp"))).toBe(true); + expect(scripts.some((script) => script.includes('mv -f -- "$1" "$2"'))).toBe(false); + expect(scripts.some((script) => script.includes('rm -f -- "$1"'))).toBe(true); + }); + it("allows mkdirp for existing in-boundary subdirectories", async () => { await withTempDir("openclaw-fs-bridge-mkdirp-", async (stateDir) => { const workspaceDir = path.join(stateDir, "workspace"); diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 2f3766a697d..92ded714f37 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -119,15 +119,23 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data, params.encoding ?? "utf8"); - const script = - params.mkdir === false - ? 'set -eu; cat >"$1"' - : 'set -eu; dir=$(dirname -- "$1"); if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi; cat >"$1"'; - await this.runCommand(script, { - args: [target.containerPath], - stdin: buffer, + const tempPath = await this.writeFileToTempPath({ + targetContainerPath: target.containerPath, + mkdir: params.mkdir !== false, + data: buffer, signal: params.signal, }); + + try { + await this.assertPathSafety(target, { action: "write files", requireWritable: true }); + await this.runCommand('set -eu; mv -f -- "$1" "$2"', { + args: [tempPath, target.containerPath], + signal: params.signal, + }); + } catch (error) { + await this.cleanupTempPath(tempPath, params.signal); + throw error; + } } async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { @@ -351,6 +359,58 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { return normalizeContainerPath(canonical); } + private async writeFileToTempPath(params: { + targetContainerPath: string; + mkdir: boolean; + data: Buffer; + signal?: AbortSignal; + }): Promise { + const script = params.mkdir + ? [ + "set -eu", + 'target="$1"', + 'dir=$(dirname -- "$target")', + 'if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi', + 'base=$(basename -- "$target")', + 'tmp=$(mktemp "$dir/.openclaw-write-$base.XXXXXX")', + 'cat >"$tmp"', + 'printf "%s\\n" "$tmp"', + ].join("\n") + : [ + "set -eu", + 'target="$1"', + 'dir=$(dirname -- "$target")', + 'base=$(basename -- "$target")', + 'tmp=$(mktemp "$dir/.openclaw-write-$base.XXXXXX")', + 'cat >"$tmp"', + 'printf "%s\\n" "$tmp"', + ].join("\n"); + const result = await this.runCommand(script, { + args: [params.targetContainerPath], + stdin: params.data, + signal: params.signal, + }); + const tempPath = result.stdout.toString("utf8").trim().split(/\r?\n/).at(-1)?.trim(); + if (!tempPath || !tempPath.startsWith("/")) { + throw new Error( + `Failed to create temporary sandbox write path for ${params.targetContainerPath}`, + ); + } + return normalizeContainerPath(tempPath); + } + + private async cleanupTempPath(tempPath: string, signal?: AbortSignal): Promise { + try { + await this.runCommand('set -eu; rm -f -- "$1"', { + args: [tempPath], + signal, + allowFailure: true, + }); + } catch { + // Best-effort cleanup only. + } + } + private ensureWriteAccess(target: SandboxResolvedFsPath, action: string) { if (!allowsWrites(this.sandbox.workspaceAccess) || !target.writable) { throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); diff --git a/src/agents/sandbox/registry.ts b/src/agents/sandbox/registry.ts index 94b1167a7b2..54bb361934b 100644 --- a/src/agents/sandbox/registry.ts +++ b/src/agents/sandbox/registry.ts @@ -1,12 +1,7 @@ -import crypto from "node:crypto"; import fs from "node:fs/promises"; -import path from "node:path"; +import { writeJsonAtomic } from "../../infra/json-files.js"; import { acquireSessionWriteLock } from "../session-write-lock.js"; -import { - SANDBOX_BROWSER_REGISTRY_PATH, - SANDBOX_REGISTRY_PATH, - SANDBOX_STATE_DIR, -} from "./constants.js"; +import { SANDBOX_BROWSER_REGISTRY_PATH, SANDBOX_REGISTRY_PATH } from "./constants.js"; export type SandboxRegistryEntry = { containerName: string; @@ -111,20 +106,7 @@ async function writeRegistryFile( registryPath: string, registry: RegistryFile, ): Promise { - await fs.mkdir(SANDBOX_STATE_DIR, { recursive: true }); - const payload = `${JSON.stringify(registry, null, 2)}\n`; - const registryDir = path.dirname(registryPath); - const tempPath = path.join( - registryDir, - `${path.basename(registryPath)}.${crypto.randomUUID()}.tmp`, - ); - await fs.writeFile(tempPath, payload, "utf-8"); - try { - await fs.rename(tempPath, registryPath); - } catch (error) { - await fs.rm(tempPath, { force: true }); - throw error; - } + await writeJsonAtomic(registryPath, registry, { trailingNewline: true }); } export async function readRegistry(): Promise { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index d721cf4ad3e..bd5a3ebb080 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -1,4 +1,3 @@ -import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { acquireSessionWriteLock } from "../../agents/session-write-lock.js"; @@ -9,6 +8,7 @@ import { archiveSessionTranscripts, cleanupArchivedSessionTranscripts, } from "../../gateway/session-utils.fs.js"; +import { writeTextAtomic } from "../../infra/json-files.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { deliveryContextFromSession, @@ -771,57 +771,34 @@ async function saveSessionStoreUnlocked( await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); const json = JSON.stringify(store, null, 2); - // Windows: use temp-file + rename for atomic writes, same as other platforms. - // Direct `writeFile` truncates the target to 0 bytes before writing, which - // allows concurrent `readFileSync` calls (from unlocked `loadSessionStore`) - // to observe an empty file and lose the session store contents. + // Windows: keep retry semantics because rename can fail while readers hold locks. if (process.platform === "win32") { - const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`; - try { - await fs.promises.writeFile(tmp, json, "utf-8"); - // Retry rename up to 5 times with increasing backoff — rename can fail - // on Windows when the target is locked by a concurrent reader. We do - // NOT fall back to writeFile or copyFile because both use CREATE_ALWAYS - // on Windows, which truncates the target to 0 bytes before writing — - // reintroducing the exact race this fix addresses. If all attempts - // fail, the temp file is cleaned up and the next save cycle (which is - // serialized by the write lock) will succeed. - for (let i = 0; i < 5; i++) { - try { - await fs.promises.rename(tmp, storePath); - break; - } catch { - if (i < 4) { - await new Promise((r) => setTimeout(r, 50 * (i + 1))); - } - // Final attempt failed — skip this save. The write lock ensures - // the next save will retry with fresh data. Log for diagnostics. - if (i === 4) { - log.warn(`rename failed after 5 attempts: ${storePath}`); - } - } - } - } catch (err) { - const code = - err && typeof err === "object" && "code" in err - ? String((err as { code?: unknown }).code) - : null; - if (code === "ENOENT") { + for (let i = 0; i < 5; i++) { + try { + await writeTextAtomic(storePath, json, { mode: 0o600 }); return; + } catch (err) { + const code = + err && typeof err === "object" && "code" in err + ? String((err as { code?: unknown }).code) + : null; + if (code === "ENOENT") { + return; + } + if (i < 4) { + await new Promise((r) => setTimeout(r, 50 * (i + 1))); + continue; + } + // Final attempt failed — skip this save. The write lock ensures + // the next save will retry with fresh data. Log for diagnostics. + log.warn(`atomic write failed after 5 attempts: ${storePath}`); } - throw err; - } finally { - await fs.promises.rm(tmp, { force: true }).catch(() => undefined); } return; } - const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`; try { - await fs.promises.writeFile(tmp, json, { mode: 0o600, encoding: "utf-8" }); - await fs.promises.rename(tmp, storePath); - // Ensure permissions are set even if rename loses them - await fs.promises.chmod(storePath, 0o600); + await writeTextAtomic(storePath, json, { mode: 0o600 }); } catch (err) { const code = err && typeof err === "object" && "code" in err @@ -832,9 +809,7 @@ async function saveSessionStoreUnlocked( // In tests the temp session-store directory may be deleted while writes are in-flight. // Best-effort: try a direct write (recreating the parent dir), otherwise ignore. try { - await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); - await fs.promises.writeFile(storePath, json, { mode: 0o600, encoding: "utf-8" }); - await fs.promises.chmod(storePath, 0o600); + await writeTextAtomic(storePath, json, { mode: 0o600 }); } catch (err2) { const code2 = err2 && typeof err2 === "object" && "code" in err2 @@ -849,8 +824,6 @@ async function saveSessionStoreUnlocked( } throw err; - } finally { - await fs.promises.rm(tmp, { force: true }); } } diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index 8c45f01777f..79bfa1cf329 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -11,6 +11,7 @@ import path from "node:path"; import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { resolveStateDir } from "../../../config/paths.js"; +import { writeFileWithinRoot } from "../../../infra/fs-safe.js"; import { createSubsystemLogger } from "../../../logging/subsystem.js"; import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; import { hasInterSessionUserProvenance } from "../../../sessions/input-provenance.js"; @@ -305,8 +306,13 @@ const saveSessionToMemory: HookHandler = async (event) => { const entry = entryParts.join("\n"); - // Write to new memory file - await fs.writeFile(memoryFilePath, entry, "utf-8"); + // Write under memory root with alias-safe file validation. + await writeFileWithinRoot({ + rootDir: memoryDir, + relativePath: filename, + data: entry, + encoding: "utf-8", + }); log.debug("Memory file written successfully"); // Log completion (but don't send user-visible confirmation - it's internal housekeeping) diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts index d71cbf7639b..15830e9ad4e 100644 --- a/src/infra/json-files.ts +++ b/src/infra/json-files.ts @@ -14,23 +14,45 @@ export async function readJsonFile(filePath: string): Promise { export async function writeJsonAtomic( filePath: string, value: unknown, - options?: { mode?: number }, + options?: { mode?: number; trailingNewline?: boolean; ensureDirMode?: number }, +) { + const text = JSON.stringify(value, null, 2); + await writeTextAtomic(filePath, text, { + mode: options?.mode, + ensureDirMode: options?.ensureDirMode, + appendTrailingNewline: options?.trailingNewline, + }); +} + +export async function writeTextAtomic( + filePath: string, + content: string, + options?: { mode?: number; ensureDirMode?: number; appendTrailingNewline?: boolean }, ) { const mode = options?.mode ?? 0o600; - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true }); - const tmp = `${filePath}.${randomUUID()}.tmp`; - await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); - try { - await fs.chmod(tmp, mode); - } catch { - // best-effort; ignore on platforms without chmod + const payload = + options?.appendTrailingNewline && !content.endsWith("\n") ? `${content}\n` : content; + const mkdirOptions: { recursive: true; mode?: number } = { recursive: true }; + if (typeof options?.ensureDirMode === "number") { + mkdirOptions.mode = options.ensureDirMode; } - await fs.rename(tmp, filePath); + await fs.mkdir(path.dirname(filePath), mkdirOptions); + const tmp = `${filePath}.${randomUUID()}.tmp`; try { - await fs.chmod(filePath, mode); - } catch { - // best-effort; ignore on platforms without chmod + await fs.writeFile(tmp, payload, "utf8"); + try { + await fs.chmod(tmp, mode); + } catch { + // best-effort; ignore on platforms without chmod + } + await fs.rename(tmp, filePath); + try { + await fs.chmod(filePath, mode); + } catch { + // best-effort; ignore on platforms without chmod + } + } finally { + await fs.rm(tmp, { force: true }).catch(() => undefined); } } diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 1ca5be21ca9..0d59bcbf0af 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -6,6 +6,7 @@ import type { loadConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { VERSION } from "../version.js"; +import { writeJsonAtomic } from "./json-files.js"; import { resolveOpenClawPackageRoot } from "./openclaw-root.js"; import { normalizeUpdateChannel, DEFAULT_PACKAGE_CHANNEL } from "./update-channels.js"; import { compareSemverStrings, resolveNpmChannelTag, checkUpdateStatus } from "./update-check.js"; @@ -124,8 +125,7 @@ async function readState(statePath: string): Promise { } async function writeState(statePath: string, state: UpdateCheckState): Promise { - await fs.mkdir(path.dirname(statePath), { recursive: true }); - await fs.writeFile(statePath, JSON.stringify(state, null, 2), "utf-8"); + await writeJsonAtomic(statePath, state); } function sameUpdateAvailable(a: UpdateAvailable | null, b: UpdateAvailable | null): boolean { diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 462b2eff7aa..4825344d358 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -1936,10 +1936,10 @@ describe("QmdMemoryManager", () => { }); it("reuses exported session markdown files when inputs are unchanged", async () => { - const writeFileSpy = vi.spyOn(fs, "writeFile"); const sessionsDir = path.join(stateDir, "agents", agentId, "sessions"); await fs.mkdir(sessionsDir, { recursive: true }); const sessionFile = path.join(sessionsDir, "session-1.jsonl"); + const exportFile = path.join(stateDir, "agents", agentId, "qmd", "sessions", "session-1.md"); await fs.writeFile( sessionFile, '{"type":"message","message":{"role":"user","content":"hello"}}\n', @@ -1962,24 +1962,17 @@ describe("QmdMemoryManager", () => { const { manager } = await createManager(); - const reasonCount = writeFileSpy.mock.calls.length; - await manager.sync({ reason: "manual" }); - const firstExportWrites = writeFileSpy.mock.calls.length; - expect(firstExportWrites).toBe(reasonCount + 1); + try { + await manager.sync({ reason: "manual" }); + const firstExport = await fs.readFile(exportFile, "utf-8"); + expect(firstExport).toContain("hello"); - await manager.sync({ reason: "manual" }); - expect(writeFileSpy.mock.calls.length).toBe(firstExportWrites); - - await fs.writeFile( - sessionFile, - '{"type":"message","message":{"role":"user","content":"follow-up update"}}\n', - "utf-8", - ); - await manager.sync({ reason: "manual" }); - expect(writeFileSpy.mock.calls.length).toBe(firstExportWrites + 1); - - await manager.close(); - writeFileSpy.mockRestore(); + await manager.sync({ reason: "manual" }); + const secondExport = await fs.readFile(exportFile, "utf-8"); + expect(secondExport).toBe(firstExport); + } finally { + await manager.close(); + } }); it("fails closed when sqlite index is busy during doc lookup or search", async () => { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index a9a4d58c371..01acf9612cd 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -6,6 +6,7 @@ import readline from "node:readline"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; +import { writeFileWithinRoot } from "../infra/fs-safe.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { materializeWindowsSpawnProgram, @@ -1410,11 +1411,17 @@ export class QmdMemoryManager implements MemorySearchManager { if (cutoff && entry.mtimeMs < cutoff) { continue; } - const target = path.join(exportDir, `${path.basename(sessionFile, ".jsonl")}.md`); + const targetName = `${path.basename(sessionFile, ".jsonl")}.md`; + const target = path.join(exportDir, targetName); tracked.add(sessionFile); const state = this.exportedSessionState.get(sessionFile); if (!state || state.hash !== entry.hash || state.mtimeMs !== entry.mtimeMs) { - await fs.writeFile(target, this.renderSessionMarkdown(entry), "utf-8"); + await writeFileWithinRoot({ + rootDir: exportDir, + relativePath: targetName, + data: this.renderSessionMarkdown(entry), + encoding: "utf-8", + }); } this.exportedSessionState.set(sessionFile, { hash: entry.hash, diff --git a/src/node-host/config.ts b/src/node-host/config.ts index ebb11614518..cec36be74ff 100644 --- a/src/node-host/config.ts +++ b/src/node-host/config.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; +import { writeJsonAtomic } from "../infra/json-files.js"; export type NodeHostGatewayConfig = { host?: string; @@ -54,14 +55,7 @@ export async function loadNodeHostConfig(): Promise { export async function saveNodeHostConfig(config: NodeHostConfig): Promise { const filePath = resolveNodeHostConfigPath(); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - const payload = JSON.stringify(config, null, 2); - await fs.writeFile(filePath, `${payload}\n`, { mode: 0o600 }); - try { - await fs.chmod(filePath, 0o600); - } catch { - // best-effort on platforms without chmod - } + await writeJsonAtomic(filePath, config, { mode: 0o600 }); } export async function ensureNodeHostConfig(): Promise { diff --git a/src/plugin-sdk/json-store.ts b/src/plugin-sdk/json-store.ts index e768aea8ada..5c08be6c561 100644 --- a/src/plugin-sdk/json-store.ts +++ b/src/plugin-sdk/json-store.ts @@ -1,6 +1,5 @@ -import crypto from "node:crypto"; import fs from "node:fs"; -import path from "node:path"; +import { writeJsonAtomic } from "../infra/json-files.js"; import { safeParseJson } from "../utils.js"; export async function readJsonFileWithFallback( @@ -24,12 +23,9 @@ export async function readJsonFileWithFallback( } export async function writeJsonFileAtomically(filePath: string, value: unknown): Promise { - const dir = path.dirname(filePath); - await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); - const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); - await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { - encoding: "utf-8", + await writeJsonAtomic(filePath, value, { + mode: 0o600, + trailingNewline: true, + ensureDirMode: 0o700, }); - await fs.promises.chmod(tmp, 0o600); - await fs.promises.rename(tmp, filePath); } diff --git a/src/telegram/update-offset-store.ts b/src/telegram/update-offset-store.ts index dddbc772c9d..b6ed5eb6b48 100644 --- a/src/telegram/update-offset-store.ts +++ b/src/telegram/update-offset-store.ts @@ -1,8 +1,8 @@ -import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; +import { writeJsonAtomic } from "../infra/json-files.js"; const STORE_VERSION = 2; @@ -104,19 +104,16 @@ export async function writeTelegramUpdateOffset(params: { env?: NodeJS.ProcessEnv; }): Promise { const filePath = resolveTelegramUpdateOffsetPath(params.accountId, params.env); - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); const payload: TelegramUpdateOffsetState = { version: STORE_VERSION, lastUpdateId: params.updateId, botId: extractBotIdFromToken(params.botToken), }; - await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { - encoding: "utf-8", + await writeJsonAtomic(filePath, payload, { + mode: 0o600, + trailingNewline: true, + ensureDirMode: 0o700, }); - await fs.chmod(tmp, 0o600); - await fs.rename(tmp, filePath); } export async function deleteTelegramUpdateOffset(params: {