mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 13:44:58 +00:00
fix: harden sandbox writes and centralize atomic file writes
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -351,6 +359,58 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
|
||||
return normalizeContainerPath(canonical);
|
||||
}
|
||||
|
||||
private async writeFileToTempPath(params: {
|
||||
targetContainerPath: string;
|
||||
mkdir: boolean;
|
||||
data: Buffer;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
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<void> {
|
||||
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}`);
|
||||
|
||||
@@ -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<T extends RegistryEntry>(
|
||||
registryPath: string,
|
||||
registry: RegistryFile<T>,
|
||||
): Promise<void> {
|
||||
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<SandboxRegistry> {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,23 +14,45 @@ export async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UpdateCheckState> {
|
||||
}
|
||||
|
||||
async function writeState(statePath: string, state: UpdateCheckState): Promise<void> {
|
||||
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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<NodeHostConfig | null> {
|
||||
|
||||
export async function saveNodeHostConfig(config: NodeHostConfig): Promise<void> {
|
||||
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<NodeHostConfig> {
|
||||
|
||||
@@ -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<T>(
|
||||
@@ -24,12 +23,9 @@ export async function readJsonFileWithFallback<T>(
|
||||
}
|
||||
|
||||
export async function writeJsonFileAtomically(filePath: string, value: unknown): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user