[codex] Extract filesystem safety primitives (#77918)

* refactor: extract filesystem safety primitives

* refactor: use fs-safe for file access helpers

* refactor: reuse fs-safe for media reads

* refactor: use fs-safe for image reads

* refactor: reuse fs-safe in qqbot media opener

* refactor: reuse fs-safe for local media checks

* refactor: consume cleaner fs-safe api

* refactor: align fs-safe json option names

* fix: preserve fs-safe migration contracts

* refactor: use fs-safe primitive subpaths

* refactor: use grouped fs-safe subpaths

* refactor: align fs-safe api usage

* refactor: adapt private state store api

* chore: refresh proof gate

* refactor: follow fs-safe json api split

* refactor: follow reduced fs-safe surface

* build: default fs-safe python helper off

* fix: preserve fs-safe plugin sdk aliases

* refactor: consolidate fs-safe usage

* refactor: unify fs-safe store usage

* refactor: trim fs-safe temp workspace usage

* refactor: hide low-level fs-safe primitives

* build: use published fs-safe package

* fix: preserve outbound recovery durability after rebase

* chore: refresh pr checks
This commit is contained in:
Peter Steinberger
2026-05-06 02:15:17 +01:00
committed by GitHub
parent 61481eb34f
commit 538605ff44
356 changed files with 4918 additions and 11913 deletions

View File

@@ -21,7 +21,8 @@ export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
} from "./host/openclaw-runtime-config.js";
export { writeFileWithinRoot } from "./host/openclaw-runtime-io.js";
export { root } from "./host/openclaw-runtime-io.js";
export { isPathInside } from "./host/fs-utils.js";
export { createSubsystemLogger } from "./host/openclaw-runtime-io.js";
export { detectMime } from "./host/openclaw-runtime-io.js";
export { resolveGlobalSingleton } from "./host/openclaw-runtime-io.js";

View File

@@ -17,6 +17,7 @@ import {
type SessionSendPolicyConfig,
splitShellArgs,
} from "./config-utils.js";
import { isPathInside } from "./fs-utils.js";
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
export type ResolvedMemoryBackendConfig = {
@@ -143,11 +144,10 @@ function canonicalizePathForContainment(rawPath: string): string {
}
function isPathInsideRoot(candidatePath: string, rootPath: string): boolean {
const relative = path.relative(
return isPathInside(
canonicalizePathForContainment(rootPath),
canonicalizePathForContainment(candidatePath),
);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function ensureUniqueName(base: string, existing: Set<string>): string {

View File

@@ -1,7 +1,11 @@
import type { Stats } from "node:fs";
import fs from "node:fs/promises";
export type RegularFileStatResult = { missing: true } | { missing: false; stat: Stats };
import "../../../../src/infra/fs-safe-defaults.js";
export { isPathInside } from "@openclaw/fs-safe/path";
export {
readRegularFile,
statRegularFile,
type RegularFileStatResult,
} from "@openclaw/fs-safe/advanced";
export { walkDirectory, type WalkDirectoryEntry } from "@openclaw/fs-safe/walk";
export function isFileMissingError(
err: unknown,
@@ -13,19 +17,3 @@ export function isFileMissingError(
(err as Partial<NodeJS.ErrnoException>).code === "ENOENT",
);
}
export async function statRegularFile(absPath: string): Promise<RegularFileStatResult> {
let stat: Stats;
try {
stat = await fs.lstat(absPath);
} catch (err) {
if (isFileMissingError(err)) {
return { missing: true };
}
throw err;
}
if (stat.isSymbolicLink() || !stat.isFile()) {
throw new Error("path required");
}
return { missing: false, stat };
}

View File

@@ -5,7 +5,13 @@ import path from "node:path";
import { CANONICAL_ROOT_MEMORY_FILENAME } from "./config-utils.js";
import { estimateStructuredEmbeddingInputBytes } from "./embedding-input-limits.js";
import { buildTextEmbeddingInput, type EmbeddingInput } from "./embedding-inputs.js";
import { isFileMissingError } from "./fs-utils.js";
import {
isFileMissingError,
readRegularFile,
statRegularFile,
walkDirectory,
type WalkDirectoryEntry,
} from "./fs-utils.js";
import {
buildMemoryMultimodalLabel,
classifyMemoryMultimodalPath,
@@ -103,36 +109,31 @@ function isAllowedMemoryFilePath(filePath: string, multimodal?: MemoryMultimodal
);
}
async function walkDir(
function shouldDescendMemoryEntry(
entry: WalkDirectoryEntry,
shouldSkipPath?: (absPath: string) => boolean,
): boolean {
if (shouldSkipPath?.(entry.path)) {
return false;
}
return entry.kind === "directory" && entry.name !== ".openclaw-repair";
}
async function collectMemoryFilesFromDir(
dir: string,
files: string[],
multimodal?: MemoryMultimodalSettings,
shouldSkipPath?: (absPath: string) => boolean,
) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (shouldSkipPath?.(full)) {
continue;
}
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
if (entry.name === ".openclaw-repair") {
continue;
}
await walkDir(full, files, multimodal, shouldSkipPath);
continue;
}
if (!entry.isFile()) {
continue;
}
if (!isAllowedMemoryFilePath(full, multimodal)) {
continue;
}
files.push(full);
}
): Promise<void> {
const scan = await walkDirectory(dir, {
symlinks: "skip",
descend: (entry) => shouldDescendMemoryEntry(entry, shouldSkipPath),
include: (entry) =>
!shouldSkipPath?.(entry.path) &&
entry.kind === "file" &&
isAllowedMemoryFilePath(entry.path, multimodal),
});
files.push(...scan.entries.map((entry) => entry.path));
}
export async function listMemoryFiles(
@@ -148,8 +149,8 @@ export async function listMemoryFiles(
const addMarkdownFile = async (absPath: string) => {
try {
const stat = await fs.lstat(absPath);
if (stat.isSymbolicLink() || !stat.isFile()) {
const stat = await statRegularFile(absPath);
if (stat.missing) {
return;
}
if (!absPath.endsWith(".md")) {
@@ -166,7 +167,7 @@ export async function listMemoryFiles(
try {
const dirStat = await fs.lstat(memoryDir);
if (!dirStat.isSymbolicLink() && dirStat.isDirectory()) {
await walkDir(memoryDir, result, multimodal, shouldSkipWorkspaceMemoryPath);
await collectMemoryFilesFromDir(memoryDir, result, multimodal, shouldSkipWorkspaceMemoryPath);
}
} catch {}
@@ -182,7 +183,12 @@ export async function listMemoryFiles(
continue;
}
if (stat.isDirectory()) {
await walkDir(inputPath, result, multimodal, shouldSkipWorkspaceMemoryPath);
await collectMemoryFilesFromDir(
inputPath,
result,
multimodal,
shouldSkipWorkspaceMemoryPath,
);
continue;
}
if (stat.isFile() && isAllowedMemoryFilePath(inputPath, multimodal)) {
@@ -215,15 +221,11 @@ export async function buildFileEntry(
workspaceDir: string,
multimodal?: MemoryMultimodalSettings,
): Promise<MemoryFileEntry | null> {
let stat;
try {
stat = await fs.stat(absPath);
} catch (err) {
if (isFileMissingError(err)) {
return null;
}
throw err;
const regularFile = await statRegularFile(absPath);
if (regularFile.missing) {
return null;
}
const stat = regularFile.stat;
const normalizedPath = path.relative(workspaceDir, absPath).replace(/\\/g, "/");
const multimodalSettings = multimodal ?? DISABLED_MULTIMODAL_SETTINGS;
const modality = classifyMemoryMultimodalPath(absPath, multimodalSettings);
@@ -233,7 +235,12 @@ export async function buildFileEntry(
}
let buffer: Buffer;
try {
buffer = await fs.readFile(absPath);
buffer = (
await readRegularFile({
filePath: absPath,
maxBytes: multimodalSettings.maxFileBytes,
})
).buffer;
} catch (err) {
if (isFileMissingError(err)) {
return null;
@@ -269,7 +276,7 @@ export async function buildFileEntry(
}
let content: string;
try {
content = await fs.readFile(absPath, "utf-8");
content = (await readRegularFile({ filePath: absPath })).buffer.toString("utf-8");
} catch (err) {
if (isFileMissingError(err)) {
return null;
@@ -296,21 +303,17 @@ async function loadMultimodalEmbeddingInput(
if (entry.kind !== "multimodal" || !entry.contentText || !entry.mimeType) {
return null;
}
let stat;
try {
stat = await fs.stat(entry.absPath);
} catch (err) {
if (isFileMissingError(err)) {
return null;
}
throw err;
const regularFile = await statRegularFile(entry.absPath);
if (regularFile.missing) {
return null;
}
const stat = regularFile.stat;
if (stat.size !== entry.size) {
return null;
}
let buffer: Buffer;
try {
buffer = await fs.readFile(entry.absPath);
buffer = (await readRegularFile({ filePath: entry.absPath, maxBytes: entry.size })).buffer;
} catch (err) {
if (isFileMissingError(err)) {
return null;

View File

@@ -4,6 +4,7 @@ export {
DEFAULT_SQLITE_WAL_TRUNCATE_INTERVAL_MS,
applyWindowsSpawnProgramPolicy,
configureSqliteWalMaintenance,
root,
createSubsystemLogger,
detectMime,
estimateStringChars,
@@ -21,7 +22,6 @@ export {
shouldIgnoreWarning,
splitShellArgs,
truncateUtf16Safe,
writeFileWithinRoot,
} from "./openclaw-runtime.js";
export type {

View File

@@ -74,7 +74,7 @@ export { isVerbose, setVerbose } from "../../../../src/globals.js";
// IO, network, and logging helpers.
export { isExecCompletionEvent } from "../../../../src/infra/heartbeat-events-filter.js";
export { writeFileWithinRoot } from "../../../../src/infra/fs-safe.js";
export { root } from "../../../../src/infra/fs-safe.js";
export { fetchWithSsrFGuard } from "../../../../src/infra/net/fetch-guard.js";
export { shouldUseEnvHttpProxyForUrl } from "../../../../src/infra/net/proxy-env.js";
export { ssrfPolicyFromHttpBaseUrlAllowedHostname } from "../../../../src/infra/net/ssrf.js";

View File

@@ -6,7 +6,7 @@ import {
resolveMemorySearchConfig,
type OpenClawConfig,
} from "./config-utils.js";
import { isFileMissingError, statRegularFile } from "./fs-utils.js";
import { isFileMissingError, isPathInside, readRegularFile, statRegularFile } from "./fs-utils.js";
import { isMemoryPath, normalizeExtraMemoryPaths } from "./internal.js";
import {
buildMemoryReadResult,
@@ -43,7 +43,11 @@ export async function readMemoryFile(params: {
continue;
}
if (stat.isDirectory()) {
if (absPath === additionalPath || absPath.startsWith(`${additionalPath}${path.sep}`)) {
if (isPathInside(additionalPath, absPath)) {
const candidateStat = await fs.lstat(absPath).catch(() => null);
if (candidateStat?.isSymbolicLink()) {
continue;
}
allowedAdditional = true;
break;
}
@@ -68,7 +72,7 @@ export async function readMemoryFile(params: {
}
let content: string;
try {
content = await fs.readFile(absPath, "utf-8");
content = (await readRegularFile({ filePath: absPath })).buffer.toString("utf-8");
} catch (err) {
if (isFileMissingError(err)) {
return { text: "", path: relPath };

View File

@@ -1,6 +1,7 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { readRegularFile, statRegularFile } from "./fs-utils.js";
import { hashText } from "./hash.js";
import { createSubsystemLogger, redactSensitiveText } from "./openclaw-runtime-io.js";
import {
@@ -524,7 +525,11 @@ export async function buildSessionEntry(
opts: BuildSessionEntryOptions = {},
): Promise<SessionFileEntry | null> {
try {
const stat = await fs.stat(absPath);
const regularFile = await statRegularFile(absPath);
if (regularFile.missing) {
return null;
}
const stat = regularFile.stat;
if (shouldSkipTranscriptFileForDreaming(absPath)) {
return {
path: sessionPathForFile(absPath),
@@ -537,7 +542,7 @@ export async function buildSessionEntry(
messageTimestampsMs: [],
};
}
const raw = await fs.readFile(absPath, "utf-8");
const raw = (await readRegularFile({ filePath: absPath })).buffer.toString("utf-8");
const lines = raw.split("\n");
const collected: string[] = [];
const lineMap: number[] = [];