[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

@@ -17,19 +17,20 @@ const childProcessMocks = vi.hoisted(() => ({
spawn: vi.fn(),
}));
const fsSafeMocks = vi.hoisted(() => {
class MockSafeOpenError extends Error {
class MockFsSafeError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "SafeOpenError";
this.name = "FsSafeError";
this.code = code;
}
}
return {
SafeOpenError: MockSafeOpenError,
copyFileWithinRoot: vi.fn(),
FsSafeError: MockFsSafeError,
rootCopyFrom: vi.fn(),
root: vi.fn(),
readLocalFileSafely: vi.fn(),
};
});
@@ -51,7 +52,7 @@ vi.mock("node:child_process", async () => {
vi.mock("../infra/fs-safe.js", () => fsSafeMocks);
vi.mock("../media/channel-inbound-roots.js", () => mediaRootMocks);
async function copyFileWithinRootForTest({
async function rootCopyFromForTest({
sourcePath,
rootDir,
relativePath,
@@ -64,7 +65,7 @@ async function copyFileWithinRootForTest({
}) {
const sourceStat = await fs.stat(sourcePath);
if (typeof maxBytes === "number" && sourceStat.size > maxBytes) {
throw new fsSafeMocks.SafeOpenError(
throw new fsSafeMocks.FsSafeError(
"too-large",
`file exceeds limit of ${maxBytes} bytes (got ${sourceStat.size})`,
);
@@ -75,7 +76,7 @@ async function copyFileWithinRootForTest({
const destPath = path.resolve(rootReal, relativePath);
const rootPrefix = `${rootReal}${path.sep}`;
if (destPath !== rootReal && !destPath.startsWith(rootPrefix)) {
throw new fsSafeMocks.SafeOpenError("outside-workspace", "file is outside workspace root");
throw new fsSafeMocks.FsSafeError("outside-workspace", "file is outside workspace root");
}
const parentDir = dirname(destPath);
@@ -87,7 +88,7 @@ async function copyFileWithinRootForTest({
try {
const stat = await fs.lstat(cursor);
if (stat.isSymbolicLink()) {
throw new fsSafeMocks.SafeOpenError("symlink", "symlink not allowed");
throw new fsSafeMocks.FsSafeError("symlink", "symlink not allowed");
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
@@ -102,7 +103,7 @@ async function copyFileWithinRootForTest({
try {
const destStat = await fs.lstat(destPath);
if (destStat.isSymbolicLink()) {
throw new fsSafeMocks.SafeOpenError("symlink", "symlink not allowed");
throw new fsSafeMocks.FsSafeError("symlink", "symlink not allowed");
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
@@ -117,7 +118,16 @@ beforeEach(() => {
sandboxMocks.ensureSandboxWorkspaceForSession.mockReset();
sandboxMocks.assertSandboxPath.mockReset().mockResolvedValue({ resolved: "", relative: "" });
childProcessMocks.spawn.mockClear();
fsSafeMocks.copyFileWithinRoot.mockReset().mockImplementation(copyFileWithinRootForTest);
fsSafeMocks.rootCopyFrom.mockReset().mockImplementation(rootCopyFromForTest);
fsSafeMocks.root.mockReset().mockImplementation(async (rootDir: string) => ({
copyFrom: async (sourcePath: string, relativePath: string, options?: { maxBytes?: number }) =>
await rootCopyFromForTest({
sourcePath,
rootDir,
relativePath,
maxBytes: options?.maxBytes,
}),
}));
mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots
.mockReset()
.mockReturnValue(["/Users/demo/Library/Messages/Attachments"]);

View File

@@ -7,6 +7,7 @@ import {
type SessionEntry as PiSessionEntry,
type SessionHeader,
} from "@mariozechner/pi-coding-agent";
import { pathExists } from "../../infra/fs-safe.js";
import type { ReplyPayload } from "../types.js";
import {
isReplyPayload,
@@ -122,15 +123,6 @@ async function generateHtml(sessionData: SessionData): Promise<string> {
].reduce((html, [name, value]) => replaceHtmlPlaceholder(html, name, value), template);
}
async function fileExists(pathName: string): Promise<boolean> {
try {
await fsp.access(pathName);
return true;
} catch {
return false;
}
}
function addCollisionSuffix(filePath: string, suffix: number): string {
const ext = path.extname(filePath);
const baseName = path.basename(filePath, ext);
@@ -152,7 +144,6 @@ async function writeNewDefaultExportFile(filePath: string, html: string): Promis
}
throw new Error(`Could not find an unused export filename near ${filePath}`);
}
async function readSessionDataFromTranscript(sessionFile: string): Promise<{
header: SessionHeader | null;
entries: PiSessionEntry[];
@@ -183,7 +174,7 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro
}
const { entry, sessionFile } = sessionTarget;
if (!(await fileExists(sessionFile))) {
if (!(await pathExists(sessionFile))) {
return { text: `❌ Session file not found: ${sessionFile}` };
}

View File

@@ -27,6 +27,11 @@ const hoisted = await vi.hoisted(async () => {
await actualAccess(file);
},
),
statMock: vi.fn(
async (file: fs.PathLike, actualStat: (path: fs.PathLike) => Promise<unknown>) => {
return await actualStat(file);
},
),
};
});
@@ -59,6 +64,7 @@ vi.mock("node:fs/promises", async () => {
const mockedFs = {
...actual,
access: (file: fs.PathLike) => hoisted.accessMock(file, actual.access),
stat: (file: fs.PathLike) => hoisted.statMock(file, actual.stat),
};
return {
...mockedFs,
@@ -67,6 +73,7 @@ vi.mock("node:fs/promises", async () => {
});
const tempDirs: string[] = [];
const mockedSessionFile = "/tmp/target-store/session.jsonl";
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-export-command-"));
@@ -173,9 +180,20 @@ describe("buildExportTrajectoryReply", () => {
await actualAccess(file);
},
);
hoisted.statMock.mockImplementation(
async (file: fs.PathLike, actualStat: (path: fs.PathLike) => Promise<unknown>) => {
if (file.toString() === "/tmp/target-store/session.jsonl") {
return {};
}
return await actualStat(file);
},
);
fs.mkdirSync(path.dirname(mockedSessionFile), { recursive: true });
fs.writeFileSync(mockedSessionFile, "{}\n");
});
afterEach(() => {
fs.rmSync(mockedSessionFile, { force: true });
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -238,6 +256,7 @@ describe("buildExportTrajectoryReply", () => {
it("does not echo absolute session paths when the transcript is missing", async () => {
const { buildExportTrajectoryReply } = await import("./commands-export-trajectory.js");
fs.rmSync(mockedSessionFile, { force: true });
hoisted.accessMock.mockImplementation(
async (file: fs.PathLike, actualAccess: (path: fs.PathLike) => Promise<void>) => {
if (file.toString() === "/tmp/target-store/session.jsonl") {
@@ -246,6 +265,14 @@ describe("buildExportTrajectoryReply", () => {
await actualAccess(file);
},
);
hoisted.statMock.mockImplementation(
async (file: fs.PathLike, actualStat: (path: fs.PathLike) => Promise<unknown>) => {
if (file.toString() === "/tmp/target-store/session.jsonl") {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
}
return await actualStat(file);
},
);
const reply = await buildExportTrajectoryReply(makeParams());

View File

@@ -1,9 +1,9 @@
import fsp from "node:fs/promises";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { createExecTool } from "../../agents/bash-tools.js";
import type { ExecToolDetails } from "../../agents/bash-tools.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type { ExecApprovalRequest } from "../../infra/exec-approvals.js";
import { pathExists } from "../../infra/fs-safe.js";
import {
exportTrajectoryForCommand,
formatTrajectoryCommandExportSummary,
@@ -56,15 +56,6 @@ const defaultExportTrajectoryCommandDeps: ExportTrajectoryCommandDeps = {
deliverPrivateTrajectoryReply: deliverPrivateTrajectoryReply,
};
async function fileExists(pathName: string): Promise<boolean> {
try {
await fsp.access(pathName);
return true;
} catch {
return false;
}
}
export async function buildExportTrajectoryCommandReply(
params: HandleCommandsParams,
deps: Partial<ExportTrajectoryCommandDeps> = {},
@@ -146,7 +137,7 @@ export async function buildExportTrajectoryReply(
}
const { entry, sessionFile } = sessionTarget;
if (!(await fileExists(sessionFile))) {
if (!(await pathExists(sessionFile))) {
return { text: "❌ Session file not found." };
}

View File

@@ -4,7 +4,7 @@ import { resolveAgentContextLimits } from "../../agents/agent-scope.js";
import { resolveCronStyleNow } from "../../agents/current-time.js";
import { resolveUserTimezone } from "../../agents/date-time.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
import { openRootFile } from "../../infra/boundary-file-read.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
const MAX_CONTEXT_CHARS = 1800;
@@ -78,7 +78,7 @@ export async function readPostCompactionContext(
const agentsPath = path.join(workspaceDir, "AGENTS.md");
try {
const opened = await openBoundaryFile({
const opened = await openRootFile({
absolutePath: agentsPath,
rootPath: workspaceDir,
boundaryLabel: "workspace root",

View File

@@ -19,6 +19,7 @@ import {
type SessionEntry as StoreSessionEntry,
} from "../../config/sessions/types.js";
import { readLatestRecentSessionUsageFromTranscriptAsync } from "../../gateway/session-utils.fs.js";
import { readRegularFile } from "../../infra/fs-safe.js";
type ForkSourceTranscript = {
cwd: string;
@@ -169,7 +170,7 @@ function collectBranchLabels(params: {
async function readForkSourceTranscript(
parentSessionFile: string,
): Promise<ForkSourceTranscript | null> {
const raw = await fs.readFile(parentSessionFile, "utf-8");
const raw = (await readRegularFile({ filePath: parentSessionFile })).buffer.toString("utf-8");
const fileEntries = parseSessionEntries(raw);
migrateSessionEntries(fileEntries);
const header =
@@ -281,15 +282,6 @@ async function writeBranchedSession(params: {
return { sessionId, sessionFile };
}
async function fileExists(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch {
return false;
}
}
export async function forkSessionFromParentRuntime(params: {
parentEntry: StoreSessionEntry;
agentId: string;
@@ -300,7 +292,7 @@ export async function forkSessionFromParentRuntime(params: {
params.parentEntry,
{ agentId: params.agentId, sessionsDir: params.sessionsDir },
);
if (!parentSessionFile || !(await fileExists(parentSessionFile))) {
if (!parentSessionFile) {
return null;
}
try {

View File

@@ -7,7 +7,7 @@ import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
import { slugifySessionKey } from "../../agents/sandbox/shared.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logVerbose } from "../../globals.js";
import { copyFileWithinRoot, SafeOpenError } from "../../infra/fs-safe.js";
import { root as fsRoot, FsSafeError } from "../../infra/fs-safe.js";
import { normalizeScpRemoteHost, normalizeScpRemotePath } from "../../infra/scp-host.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { resolveChannelRemoteInboundAttachmentRoots } from "../../media/channel-inbound-roots.js";
@@ -107,7 +107,7 @@ export async function stageSandboxMedia(params: {
});
}
} catch (err) {
if (err instanceof SafeOpenError && err.code === "too-large") {
if (err instanceof FsSafeError && err.code === "too-large") {
logVerbose(
`Blocking inbound media staging above ${STAGED_MEDIA_MAX_BYTES} bytes: ${source}`,
);
@@ -139,10 +139,8 @@ async function stageLocalFileIntoRoot(params: {
relativeDestPath: string;
maxBytes?: number;
}): Promise<void> {
await copyFileWithinRoot({
sourcePath: params.sourcePath,
rootDir: params.rootDir,
relativePath: params.relativeDestPath,
const root = await fsRoot(params.rootDir);
await root.copyIn(params.relativeDestPath, params.sourcePath, {
maxBytes: params.maxBytes,
});
}

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { resolveUserTimezone } from "../../agents/date-time.js";
import type { OpenClawConfig } from "../../config/config.js";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
import { openRootFile } from "../../infra/boundary-file-read.js";
const STARTUP_MEMORY_FILE_MAX_BYTES = 16_384;
const STARTUP_MEMORY_FILE_MAX_CHARS = 1_200;
@@ -205,7 +205,7 @@ async function readStartupMemoryFile(params: {
maxFileBytes: number;
}): Promise<string | null> {
const absolutePath = path.join(params.workspaceDir, params.relativePath);
const opened = await openBoundaryFile({
const opened = await openRootFile({
absolutePath,
rootPath: params.workspaceDir,
boundaryLabel: "workspace root",