mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-15 15:40:45 +00:00
[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:
committed by
GitHub
parent
61481eb34f
commit
538605ff44
@@ -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"]);
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user