fix: harden sandbox writes and centralize atomic file writes

This commit is contained in:
Peter Steinberger
2026-03-02 16:44:46 +00:00
parent 14e4575af5
commit 18f8393b6c
12 changed files with 203 additions and 139 deletions

View File

@@ -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");

View File

@@ -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}`);

View File

@@ -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> {

View File

@@ -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 });
}
}

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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> {

View File

@@ -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);
}

View File

@@ -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: {