Files
clawX/electron/utils/session-files.ts
2026-05-12 19:09:25 +08:00

272 lines
11 KiB
TypeScript

/**
* Session file helpers
*
* Shared between the Main IPC `session:delete` handler and the HTTP mirror
* `POST /api/sessions/delete`. Both surfaces must agree on:
*
* 1. how a `sessionKey` (e.g. `agent:main:session-1234`) is resolved to an
* on-disk transcript path via `sessions.json`, and
* 2. which sibling artefacts are swept when the user hard-deletes a
* conversation:
* - `<baseId>.jsonl` the live transcript
* - `<baseId>.deleted.jsonl` legacy soft-delete leftover
* - `<baseId>.jsonl.reset.*` reset snapshots from sessions.reset
* - `<baseId>.trajectory.jsonl` OpenClaw runtime "flight recorder"
* (default location, beside session)
* - `<baseId>.trajectory-path.json` pointer sidecar; when present we
* follow `runtimeFile` so the actual
* trajectory still gets unlinked even
* if `OPENCLAW_TRAJECTORY_DIR` moved
* it out of the sessions/ folder
*
* The helper enforces that the resolved session path lives inside the
* agent's `sessions/` directory so a corrupt or malicious `sessions.json`
* can never steer the unlink loop into an unrelated folder. The pointer-
* follow path is the only deliberate exception: it walks to whatever
* `runtimeFile` says (defended by schema + extension checks) so ClawX can
* cooperate with OpenClaw's documented `OPENCLAW_TRAJECTORY_DIR` override.
*/
import { promises as fsP } from 'node:fs';
import path from 'node:path';
export type SessionResolutionFailure =
| { kind: 'not-found' }
| { kind: 'path-outside-scope'; resolvedPath: string };
export type SessionResolutionResult =
| { ok: true; resolvedSrcPath: string; sessionsDirAbs: string; baseId: string }
| { ok: false; failure: SessionResolutionFailure };
/**
* `path.isAbsolute` only respects the *current* platform's rules. OpenClaw
* may be running in a context (or have been migrated from a profile) where
* the on-disk `sessionFile` uses Windows-style backslash paths, Windows
* forward-slash paths (`C:/...`) or POSIX paths. Accepting all three keeps
* the IPC handler portable across Win32 + macOS + Linux installs.
*/
function isAnyAbsolute(candidate: string): boolean {
return path.isAbsolute(candidate) || path.win32.isAbsolute(candidate);
}
/**
* Resolve the absolute on-disk transcript path for `sessionKey` from a parsed
* `sessions.json` blob. Returns `{ ok: false, failure: 'not-found' }` when no
* matching entry is found and `{ ok: false, failure: 'path-outside-scope' }`
* when the resolved path escapes `sessionsDir` (defence-in-depth).
*
* Supports the three sessions.json shapes observed in the wild:
* A) `{ sessions: [{ key, file, ... }] }` (array, file field)
* B) `{ [sessionKey]: { sessionFile|file|...|id } }` (object-keyed, current)
* C) `{ sessions: [{ key, id, ... }] }` (array, id field only)
*/
export function resolveSessionTranscriptPath(
sessionsJson: Record<string, unknown>,
sessionsDir: string,
sessionKey: string,
): SessionResolutionResult {
let uuidFileName: string | undefined;
let resolvedSrcPath: string | undefined;
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
if (entry) {
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!uuidFileName && typeof entry.id === 'string') {
uuidFileName = `${entry.id}.jsonl`;
}
}
}
if (!uuidFileName && sessionsJson[sessionKey] != null) {
const val = sessionsJson[sessionKey];
if (typeof val === 'string') {
uuidFileName = val;
} else if (typeof val === 'object' && val !== null) {
const entry = val as Record<string, unknown>;
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (isAnyAbsolute(absFile)) {
resolvedSrcPath = absFile;
} else {
uuidFileName = absFile;
}
} else {
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
}
}
}
if (!uuidFileName && !resolvedSrcPath) {
return { ok: false, failure: { kind: 'not-found' } };
}
if (!resolvedSrcPath) {
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
resolvedSrcPath = path.join(sessionsDir, uuidFileName!);
}
const sessionsDirAbs = path.dirname(resolvedSrcPath);
// sessionsDir is always built from `getOpenClawConfigDir()` + the validated
// agentId, so anything that doesn't resolve underneath it is suspect. We
// refuse rather than try to "normalise" the entry — the worst case for the
// user is that the sidebar entry stops listing it; the worst case if we
// proceeded is `unlink`s in someone else's directory.
const rel = path.relative(sessionsDir, sessionsDirAbs);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return { ok: false, failure: { kind: 'path-outside-scope', resolvedPath: resolvedSrcPath } };
}
const baseId = path.basename(resolvedSrcPath).replace(/\.jsonl$/, '');
return { ok: true, resolvedSrcPath, sessionsDirAbs, baseId };
}
export interface SweepResult {
removed: string[];
errors: Array<{ path: string; error: NodeJS.ErrnoException }>;
}
/**
* Schema marker written by OpenClaw's `writeTrajectoryPointerBestEffort`.
* Any pointer file that doesn't carry this exact `traceSchema` is treated
* as untrusted and ignored — we only follow pointers we know OpenClaw
* authored.
*/
const TRAJECTORY_POINTER_SCHEMA = 'openclaw-trajectory-pointer';
/**
* Best-effort: parse the trajectory pointer sidecar and return the off-disk
* runtime file path it points at. Returns null when the pointer is absent,
* malformed, missing the openclaw-trajectory-pointer schema, or its
* `runtimeFile` is not an absolute `.jsonl` path. Any of those conditions
* means we silently skip the off-disk unlink — the local sidecar sweep
* still runs and the worst case is one orphaned file the user can clean up
* manually.
*/
async function readTrajectoryRuntimeFile(pointerPath: string): Promise<string | null> {
let raw: string;
try {
raw = await fsP.readFile(pointerPath, 'utf8');
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const obj = parsed as Record<string, unknown>;
if (obj.traceSchema !== TRAJECTORY_POINTER_SCHEMA) return null;
const runtimeFile = obj.runtimeFile;
if (typeof runtimeFile !== 'string' || runtimeFile.length === 0) return null;
// OpenClaw always writes a `.jsonl` path; refusing anything else keeps us
// from being weaponised into deleting (say) `/etc/passwd` if a hostile
// sessions.json/pointer combination ever showed up on disk.
if (!runtimeFile.endsWith('.jsonl')) return null;
if (!path.isAbsolute(runtimeFile) && !path.win32.isAbsolute(runtimeFile)) return null;
return runtimeFile;
}
/**
* Hard-delete every artefact that belongs to `baseId` inside
* `sessionsDirAbs`. ENOENT is tolerated for both the directory itself and
* each individual file — by the time the sweep runs we've already committed
* to the deletion, so missing files just mean a previous sweep got there
* first. Other errors are accumulated so the caller can log/surface them.
*
* In addition to the local sidecars (`.jsonl`, `.deleted.jsonl`,
* `.jsonl.reset.*`, `.trajectory.jsonl`, `.trajectory-path.json`), the
* sweep follows the `.trajectory-path.json` pointer when it exists and
* unlinks the off-disk runtime file at `runtimeFile`. This keeps ClawX in
* sync with OpenClaw's `OPENCLAW_TRAJECTORY_DIR` override (where the
* actual trajectory is stored outside the sessions/ folder).
*/
export async function sweepSessionArtefacts(
sessionsDirAbs: string,
baseId: string,
): Promise<SweepResult> {
const result: SweepResult = { removed: [], errors: [] };
let dirEntries: string[];
try {
dirEntries = await fsP.readdir(sessionsDirAbs);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return result;
result.errors.push({ path: sessionsDirAbs, error: err as NodeJS.ErrnoException });
return result;
}
const pointerName = `${baseId}.trajectory-path.json`;
const targets = dirEntries.filter((name) =>
name === `${baseId}.jsonl`
|| name === `${baseId}.deleted.jsonl`
|| name === `${baseId}.trajectory.jsonl`
|| name === pointerName
|| name.startsWith(`${baseId}.jsonl.reset.`),
);
// Follow the pointer FIRST so the off-disk runtime file (lives outside
// sessionsDirAbs when OPENCLAW_TRAJECTORY_DIR is set) gets unlinked
// before we delete its only on-disk reference. If the pointer is gone,
// malformed, or carries an in-sessions-dir path, this is a no-op and the
// standard local sweep below covers everything.
if (targets.includes(pointerName)) {
const pointerPath = path.join(sessionsDirAbs, pointerName);
const runtimeFile = await readTrajectoryRuntimeFile(pointerPath);
if (runtimeFile) {
const absRuntime = path.resolve(runtimeFile);
const rel = path.relative(sessionsDirAbs, absRuntime);
// Only chase pointers that escape the sessions dir. Anything pointing
// back inside is already covered by the local-target sweep below
// (and would risk a double-unlink / double-error otherwise).
if (rel.startsWith('..') || path.isAbsolute(rel)) {
try {
await fsP.unlink(absRuntime);
result.removed.push(absRuntime);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
result.errors.push({ path: absRuntime, error: err as NodeJS.ErrnoException });
}
}
}
}
}
await Promise.all(targets.map(async (name) => {
const target = path.join(sessionsDirAbs, name);
try {
await fsP.unlink(target);
result.removed.push(target);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return;
result.errors.push({ path: target, error: err as NodeJS.ErrnoException });
}
}));
return result;
}
/**
* Remove `sessionKey` from a parsed sessions.json object (mutates in place).
* Handles both the array-shape (`{ sessions: [...] }`) and the object-keyed
* shape so the IPC handler and HTTP route share one rewrite path.
*/
export function removeSessionEntry(
sessionsJson: Record<string, unknown>,
sessionKey: string,
): void {
if (Array.isArray(sessionsJson.sessions)) {
sessionsJson.sessions = (sessionsJson.sessions as Array<Record<string, unknown>>)
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
} else if (sessionsJson[sessionKey]) {
delete sessionsJson[sessionKey];
}
}