Files
openclaw/src/trajectory/cleanup.ts
Peter Steinberger 538605ff44 [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
2026-05-06 02:15:17 +01:00

253 lines
6.8 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { resolveSessionFilePath } from "../config/sessions/paths.js";
import { isPathInside } from "../infra/path-guards.js";
import {
resolveTrajectoryFilePath,
resolveTrajectoryPointerFilePath,
safeTrajectorySessionFileName,
} from "./paths.js";
export type RemovedTrajectoryArtifact = {
kind: "pointer" | "runtime";
path: string;
};
type TrajectoryPointer = {
runtimeFile: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function canonicalizePathForComparison(filePath: string): string {
const resolved = path.resolve(filePath);
try {
return fs.realpathSync(resolved);
} catch {
return resolved;
}
}
function isPathWithinDir(parentDir: string, filePath: string): boolean {
const resolvedParent = canonicalizePathForComparison(parentDir);
const resolvedFile = canonicalizePathForComparison(filePath);
return resolvedFile !== resolvedParent && isPathInside(resolvedParent, resolvedFile);
}
function isRegularNonSymlinkFile(filePath: string): boolean {
try {
const lst = fs.lstatSync(filePath);
if (!lst.isFile() || lst.isSymbolicLink()) {
return false;
}
return fs.statSync(filePath).isFile();
} catch {
return false;
}
}
function readTrajectoryPointerFile(
pointerPath: string,
sessionId: string,
): TrajectoryPointer | null {
if (!isRegularNonSymlinkFile(pointerPath)) {
return null;
}
try {
const parsed: unknown = JSON.parse(fs.readFileSync(pointerPath, "utf8"));
if (!isRecord(parsed)) {
return null;
}
if (
parsed.traceSchema !== "openclaw-trajectory-pointer" ||
parsed.schemaVersion !== 1 ||
parsed.sessionId !== sessionId ||
typeof parsed.runtimeFile !== "string" ||
!parsed.runtimeFile.trim()
) {
return null;
}
return { runtimeFile: path.resolve(parsed.runtimeFile) };
} catch {
return null;
}
}
function readFirstNonEmptyLine(filePath: string): string | null {
let fd: number | null = null;
try {
fd = fs.openSync(filePath, "r");
const buffer = Buffer.alloc(64 * 1024);
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
if (bytesRead <= 0) {
return null;
}
for (const line of buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/u)) {
const trimmed = line.trim();
if (trimmed) {
return trimmed;
}
}
return null;
} catch {
return null;
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch {
// Ignore best-effort cleanup close failures.
}
}
}
}
function runtimeFileStartsWithSessionEvent(filePath: string, sessionId: string): boolean {
if (!isRegularNonSymlinkFile(filePath)) {
return false;
}
const firstLine = readFirstNonEmptyLine(filePath);
if (!firstLine) {
return false;
}
try {
const parsed: unknown = JSON.parse(firstLine);
return (
isRecord(parsed) &&
parsed.traceSchema === "openclaw-trajectory" &&
parsed.schemaVersion === 1 &&
parsed.source === "runtime" &&
parsed.sessionId === sessionId
);
} catch {
return false;
}
}
async function removeRegularFile(
filePath: string,
kind: RemovedTrajectoryArtifact["kind"],
): Promise<RemovedTrajectoryArtifact | null> {
if (!isRegularNonSymlinkFile(filePath)) {
return null;
}
await fs.promises.rm(filePath, { force: true });
return { kind, path: path.resolve(filePath) };
}
function resolveRemovedSessionFile(params: {
sessionId: string;
sessionFile?: string;
storePath: string;
}): string | null {
try {
return resolveSessionFilePath(
params.sessionId,
params.sessionFile ? { sessionFile: params.sessionFile } : undefined,
{ sessionsDir: path.dirname(params.storePath) },
);
} catch {
return null;
}
}
function mayRemoveRuntimeTarget(params: {
defaultRuntimePath: string;
filePath: string;
sessionId: string;
storeDir: string;
restrictToStoreDir: boolean;
}): boolean {
const resolved = canonicalizePathForComparison(params.filePath);
const withinStoreDir = isPathWithinDir(params.storeDir, resolved);
if (canonicalizePathForComparison(params.defaultRuntimePath) === resolved) {
return !params.restrictToStoreDir || withinStoreDir;
}
if (params.restrictToStoreDir && withinStoreDir) {
return true;
}
const expectedName = `${safeTrajectorySessionFileName(params.sessionId)}.jsonl`;
if (path.basename(resolved) !== expectedName) {
return false;
}
return runtimeFileStartsWithSessionEvent(resolved, params.sessionId);
}
export async function removeSessionTrajectoryArtifacts(params: {
sessionId: string;
sessionFile?: string;
storePath: string;
restrictToStoreDir?: boolean;
}): Promise<RemovedTrajectoryArtifact[]> {
const sessionFile = resolveRemovedSessionFile(params);
if (!sessionFile) {
return [];
}
const storeDir = path.dirname(path.resolve(params.storePath));
const restrictToStoreDir = params.restrictToStoreDir === true;
const removed: RemovedTrajectoryArtifact[] = [];
const pointerPath = resolveTrajectoryPointerFilePath(sessionFile);
const pointer = readTrajectoryPointerFile(pointerPath, params.sessionId);
const defaultRuntimePath = resolveTrajectoryFilePath({
env: {},
sessionFile,
sessionId: params.sessionId,
});
const runtimeCandidates = new Set<string>([defaultRuntimePath]);
if (pointer?.runtimeFile) {
runtimeCandidates.add(pointer.runtimeFile);
}
for (const runtimePath of runtimeCandidates) {
if (
!mayRemoveRuntimeTarget({
defaultRuntimePath,
filePath: runtimePath,
sessionId: params.sessionId,
storeDir,
restrictToStoreDir,
})
) {
continue;
}
const deleted = await removeRegularFile(runtimePath, "runtime");
if (deleted) {
removed.push(deleted);
}
}
if (!restrictToStoreDir || isPathWithinDir(storeDir, pointerPath)) {
const deletedPointer = await removeRegularFile(pointerPath, "pointer");
if (deletedPointer) {
removed.push(deletedPointer);
}
}
return removed;
}
export async function removeRemovedSessionTrajectoryArtifacts(params: {
removedSessionFiles: Iterable<[string, string | undefined]>;
referencedSessionIds: ReadonlySet<string>;
storePath: string;
restrictToStoreDir?: boolean;
}): Promise<RemovedTrajectoryArtifact[]> {
const removed: RemovedTrajectoryArtifact[] = [];
for (const [sessionId, sessionFile] of params.removedSessionFiles) {
if (params.referencedSessionIds.has(sessionId)) {
continue;
}
removed.push(
...(await removeSessionTrajectoryArtifacts({
sessionId,
sessionFile,
storePath: params.storePath,
restrictToStoreDir: params.restrictToStoreDir,
})),
);
}
return removed;
}