feat(session): hard-delete JSONL transcript on conversation delete (#986)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
Haze
2026-05-12 19:09:25 +08:00
committed by GitHub
parent 2327f0ba6a
commit 58aa88b3ae
11 changed files with 944 additions and 142 deletions

View File

@@ -33,7 +33,7 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
- **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state).
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records. Note: "Delete conversation" in the sidebar is a hard delete — the Main process unlinks `<id>.jsonl` plus any leftover `<id>.deleted.jsonl` and `<id>.jsonl.reset.*` siblings, *and* OpenClaw's trajectory artefacts (`<id>.trajectory.jsonl` flight recorder + `<id>.trajectory-path.json` pointer); when the pointer references a runtime file outside the agent's `sessions/` folder (the `OPENCLAW_TRAJECTORY_DIR` override), that off-disk file is unlinked too. Deleted conversations stop contributing to this chart — use a fresh session if you want history retained.
- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries.
- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly.
- **UI change validation**: Any user-visible UI change should include or update an Electron E2E spec in the same PR so the interaction is covered by Playwright.

View File

@@ -1,6 +1,12 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { join } from 'node:path';
import { getOpenClawConfigDir } from '../../utils/paths';
import {
removeSessionEntry,
resolveSessionTranscriptPath,
sweepSessionArtefacts,
} from '../../utils/session-files';
import { logger } from '../../utils/logger';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
@@ -49,6 +55,13 @@ export async function handleSessionRoutes(
return true;
}
// POST /api/sessions/delete — HTTP mirror of the `session:delete` IPC.
// Both surfaces share electron/utils/session-files.ts so they sweep the
// same set of artefacts: the live transcript, legacy `.deleted.jsonl`,
// `.jsonl.reset.*` snapshots, the trajectory sidecar pair
// (`<id>.trajectory.jsonl` + `<id>.trajectory-path.json`) and — when the
// pointer points outside sessions/ (the OPENCLAW_TRAJECTORY_DIR case) —
// the off-disk runtime trajectory it references.
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKey: string }>(req);
@@ -63,66 +76,39 @@ export async function handleSessionRoutes(
return true;
}
const agentId = parts[1];
// Defence-in-depth: agentId becomes a path segment under
// ~/.openclaw/agents/. The sibling /api/sessions/transcript route
// applies the same check to its sessionId; mirror it here so a
// malformed key can never steer the unlink loop into another folder.
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
sendJson(res, 400, { success: false, error: `Invalid agentId: ${agentId}` });
return true;
}
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
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`;
}
const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey);
if (!resolution.ok) {
if (resolution.failure.kind === 'not-found') {
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
} else {
logger.warn(`[api/sessions/delete] Refusing out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`);
sendJson(res, 400, { success: false, error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}` });
}
}
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 (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
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) {
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
return true;
}
if (!resolvedSrcPath) {
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
resolvedSrcPath = join(sessionsDir, uuidFileName!);
}
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
try {
await fsP.access(resolvedSrcPath);
await fsP.rename(resolvedSrcPath, dstPath);
} catch {
// Non-fatal; still try to update sessions.json.
const sweep = await sweepSessionArtefacts(resolution.sessionsDirAbs, resolution.baseId);
for (const { path: failedPath, error } of sweep.errors) {
logger.warn(`[api/sessions/delete] Failed to unlink ${failedPath}: ${String(error)}`);
}
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
const json2 = JSON.parse(raw2) as Record<string, unknown>;
if (Array.isArray(json2.sessions)) {
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
} else if (json2[sessionKey]) {
delete json2[sessionKey];
}
removeSessionEntry(json2, sessionKey);
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
sendJson(res, 200, { success: true });
} catch (error) {

View File

@@ -24,6 +24,11 @@ import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui';
import { logger } from '../utils/logger';
import { resolveAgentIdFromChannel } from '../utils/agent-config';
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
import {
removeSessionEntry,
resolveSessionTranscriptPath,
sweepSessionArtefacts,
} from '../utils/session-files';
import {
saveChannelConfig,
getChannelConfig,
@@ -2586,11 +2591,31 @@ async function resolveOutgoingMediaUrl(
/**
* Session IPC handlers
*
* Performs a soft-delete of a session's JSONL transcript on disk.
* Performs a HARD delete of a session's JSONL transcript on disk.
* sessionKey format: "agent:<agentId>:<suffix>" — e.g. "agent:main:session-1234567890".
* The JSONL file lives at: ~/.openclaw/agents/<agentId>/sessions/<suffix>.jsonl
* Renaming to <suffix>.deleted.jsonl hides it from sessions.list.
* The JSONL file lives at: ~/.openclaw/agents/<agentId>/sessions/<id>.jsonl
* (where <id> is typically a UUID resolved via sessions.json).
*
* For each deleted session we unlink every file that belongs to its on-disk id:
* - <id>.jsonl — the live transcript
* - <id>.deleted.jsonl — leftovers from earlier soft-delete releases
* - <id>.jsonl.reset.* — historical snapshots produced by sessions.reset
* - <id>.trajectory.jsonl — OpenClaw runtime "flight recorder" sidecar
* - <id>.trajectory-path.json — pointer to the runtime trajectory; if it
* points outside the sessions/ folder
* (OPENCLAW_TRAJECTORY_DIR override) the
* referenced file is unlinked too.
*
* The session entry is also removed from sessions.json so sessions.list stops
* surfacing it. Token-usage history reported by the Dashboard reads the same
* transcripts, so deleted conversations stop contributing to the chart.
*
* Path resolution and the sibling sweep are shared with the HTTP mirror at
* `electron/api/routes/sessions.ts` via `electron/utils/session-files.ts`,
* so both surfaces unlink the same set of files for a given session id.
*/
const SAFE_AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
function registerSessionHandlers(): void {
ipcMain.handle('session:delete', async (_, sessionKey: string) => {
try {
@@ -2604,6 +2629,13 @@ function registerSessionHandlers(): void {
}
const agentId = parts[1];
// Defence-in-depth: agentId becomes a path segment under
// ~/.openclaw/agents/. Reject anything that could escape that root
// (".." segments, slashes, NULs, etc.) before touching the FS.
if (!SAFE_AGENT_ID.test(agentId)) {
return { success: false, error: `Invalid agentId: ${agentId}` };
}
const openclawConfigDir = getOpenClawConfigDir();
const sessionsDir = join(openclawConfigDir, 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
@@ -2623,94 +2655,41 @@ function registerSessionHandlers(): void {
return { success: false, error: `Could not read sessions.json: ${String(e)}` };
}
// sessions.json structure: try common shapes used by OpenClaw Gateway:
// Shape A (array): { sessions: [{ key, file, ... }] }
// Shape B (object): { [sessionKey]: { file, ... } }
// Shape C (array): { sessions: [{ key, id, ... }] } — id is the UUID
let uuidFileName: string | undefined;
// Shape A / C — array under "sessions" key
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
if (entry) {
// Could be "file", "fileName", "id" + ".jsonl", or "path"
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!uuidFileName && typeof entry.id === 'string') {
uuidFileName = `${entry.id}.jsonl`;
}
const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey);
if (!resolution.ok) {
if (resolution.failure.kind === 'not-found') {
const rawVal = sessionsJson[sessionKey];
logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(rawVal)}`);
return { success: false, error: `Cannot resolve file for session: ${sessionKey}` };
}
logger.warn(`[session:delete] Refusing to delete out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`);
return { success: false, error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}` };
}
// Shape B — flat object keyed by sessionKey; value may be a string or an object.
// Actual Gateway format: { sessionFile: "/abs/path/uuid.jsonl", sessionId: "uuid", ... }
let resolvedSrcPath: string | undefined;
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>;
// Priority: absolute sessionFile path > relative file/fileName/path > id/sessionId as UUID
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
// Absolute path — use directly
resolvedSrcPath = absFile;
} else {
uuidFileName = absFile;
}
} else {
// Fall back to UUID fields
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
}
}
}
if (!uuidFileName && !resolvedSrcPath) {
const rawVal = sessionsJson[sessionKey];
logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(rawVal)}`);
return { success: false, error: `Cannot resolve file for session: ${sessionKey}` };
}
// Normalise: if we got a relative filename, resolve it against sessionsDir
if (!resolvedSrcPath) {
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
resolvedSrcPath = join(sessionsDir, uuidFileName!);
}
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
const { resolvedSrcPath, sessionsDirAbs, baseId } = resolution;
logger.info(`[session:delete] file: ${resolvedSrcPath}`);
// ── Step 2: rename the JSONL file ──
try {
await fsP.access(resolvedSrcPath);
await fsP.rename(resolvedSrcPath, dstPath);
logger.info(`[session:delete] Renamed ${resolvedSrcPath}${dstPath}`);
} catch (e) {
logger.warn(`[session:delete] Could not rename file: ${String(e)}`);
// ── Step 2: hard-delete the JSONL transcript and its siblings ──
const sweep = await sweepSessionArtefacts(sessionsDirAbs, baseId);
for (const removedPath of sweep.removed) {
logger.info(`[session:delete] Unlinked ${removedPath}`);
}
for (const { path: failedPath, error } of sweep.errors) {
logger.warn(`[session:delete] Failed to unlink ${failedPath}: ${String(error)}`);
}
logger.info(`[session:delete] Hard-deleted ${sweep.removed.length} file(s) for ${baseId}`);
// ── Step 3: remove the entry from sessions.json ──
try {
// Re-read to avoid race conditions
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
const json2 = JSON.parse(raw2) as Record<string, unknown>;
if (Array.isArray(json2.sessions)) {
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
} else if (json2[sessionKey]) {
delete json2[sessionKey];
}
removeSessionEntry(json2, sessionKey);
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
logger.info(`[session:delete] Removed "${sessionKey}" from sessions.json`);
} catch (e) {
logger.warn(`[session:delete] Could not update sessions.json: ${String(e)}`);
// Non-fatal — JSONL rename already done
// Non-fatal — transcript files were already unlinked.
}
return { success: true };

View File

@@ -0,0 +1,271 @@
/**
* 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];
}
}

View File

@@ -0,0 +1,71 @@
---
id: hard-delete-session-jsonl
title: Hard-delete session JSONL on conversation delete
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Remove the on-disk session transcript (and its sibling artefacts) when the user deletes a conversation, instead of soft-deleting it via rename.
touchedAreas:
- electron/main/ipc-handlers.ts
- electron/api/routes/sessions.ts
- electron/utils/session-files.ts
- src/stores/chat/session-actions.ts
- src/stores/chat.ts
- tests/unit/session-delete-route.test.ts
- harness/specs/tasks/hard-delete-session-jsonl.md
- AGENTS.md
expectedUserBehavior:
- Confirming "Delete" in the sidebar conversation menu removes <id>.jsonl, <id>.deleted.jsonl and any <id>.jsonl.reset.* siblings from the agent's sessions folder.
- OpenClaw's trajectory artefacts for the same session id are removed too — both the local <id>.trajectory.jsonl flight recorder and the <id>.trajectory-path.json pointer sidecar.
- When the pointer's runtimeFile points outside the sessions/ folder (the OPENCLAW_TRAJECTORY_DIR override), that off-disk runtime file is also unlinked so no orphan trajectory remains anywhere on disk.
- The session entry is removed from sessions.json so OpenClaw sessions.list stops returning it.
- The sidebar list, sessionLabels and sessionLastActivity for the deleted key are cleared in the renderer store.
- Token usage history reported by the Dashboard stops including the deleted session.
requiredProfiles:
- fast
- comms
requiredTests:
- tests/unit/session-delete-route.test.ts
- tests/unit/chat-session-actions.test.ts
acceptance:
- Renderer continues to use src/lib/host-api.ts and src/lib/api-client.ts; no new direct ipcRenderer or Gateway HTTP calls.
- IPC channel name session:delete and HTTP route POST /api/sessions/delete are unchanged in shape.
- Both the IPC handler in electron/main/ipc-handlers.ts and the HTTP mirror in electron/api/routes/sessions.ts unlink the same set of files for a given session id, sharing electron/utils/session-files.ts so the disk contract cannot drift.
- The handler tolerates ENOENT (file already gone) and still updates sessions.json so the sidebar stops listing the entry.
- agentId from the sessionKey is validated against /^[A-Za-z0-9][A-Za-z0-9_-]*$/ in both surfaces and any sessionFile resolved to a path outside the agent sessions/ directory is refused (defence-in-depth against a corrupt sessions.json).
- Absolute-path detection accepts POSIX paths, Windows backslash paths (C:\...) and Windows forward-slash paths (C:/...) so the sweep works on every supported OS.
- The sweep also unlinks <id>.trajectory.jsonl and <id>.trajectory-path.json sidecars produced by OpenClaw's runtime trajectory writer.
- When <id>.trajectory-path.json carries the openclaw-trajectory-pointer schema and an absolute .jsonl runtimeFile that lives outside the sessions/ folder, the off-disk runtime trajectory is unlinked too (covers the OPENCLAW_TRAJECTORY_DIR override). Pointers with the wrong schema, a non-.jsonl runtimeFile, or a non-absolute runtimeFile are ignored and no extra files are touched.
docs:
required: true
---
Conversation deletion in ClawX runs entirely on the Main process — the
OpenClaw Gateway does not expose a `sessions.delete` RPC. Historically the
operation was a soft delete: the live `<id>.jsonl` transcript was renamed to
`<id>.deleted.jsonl` so `sessions.list` would skip it. This task replaces
that rename with a true `unlink` plus a sibling sweep that also removes
`<id>.deleted.jsonl` (legacy soft-delete leftovers) and `<id>.jsonl.reset.*`
(reset snapshots produced by `sessions.reset`).
Both backends (the IPC handler used by the refactored chat store and the
HTTP route used by the legacy chat store) share the same disk contract via
`electron/utils/session-files.ts`, which centralises:
- sessions.json entry resolution across the three observed shapes,
- cross-platform absolute-path detection (POSIX, Windows `C:\...` and
Windows `C:/...`) using `path.isAbsolute` + `path.win32.isAbsolute`,
- a defence-in-depth scope check that refuses any `sessionFile` whose
resolved directory escapes the agent's `sessions/` folder,
- the sibling sweep (`.jsonl`, `.deleted.jsonl`, `.jsonl.reset.*`,
`.trajectory.jsonl`, `.trajectory-path.json`) with ENOENT tolerance, and
- the trajectory pointer-follow that handles OpenClaw's
`OPENCLAW_TRAJECTORY_DIR` override: when the pointer is well-formed
(`traceSchema === "openclaw-trajectory-pointer"` and `runtimeFile` is
an absolute `.jsonl` outside sessions/), the off-disk runtime file is
unlinked too. Malformed or unsafe pointers are ignored.
Renderer surface is untouched: the confirm dialog in `Sidebar.tsx`, the
`useChatStore.deleteSession` API, and the host-api/api-client boundary all
continue to work without changes. The only user-visible effect is that
deleted conversations no longer leave hidden `.deleted.jsonl` files behind
and no longer contribute to Dashboard token-usage history.

View File

@@ -290,7 +290,10 @@ export function Sidebar() {
<Button
variant="ghost"
size="icon"
className="no-drag h-8 w-8 shrink-0 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10"
className={cn(
'no-drag h-8 w-8 shrink-0 rounded-lg text-foreground/80',
'hover:bg-black/5 hover:text-foreground/80 dark:hover:bg-white/5',
)}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
>
{sidebarCollapsed ? (
@@ -304,6 +307,7 @@ export function Sidebar() {
{/* Navigation */}
<nav className="flex flex-col gap-0 px-2">
<button
type="button"
data-testid="sidebar-new-chat"
onClick={() => {
const { messages } = useChatStore.getState();
@@ -311,8 +315,8 @@ export function Sidebar() {
navigate('/');
}}
className={cn(
'sidebar-nav-text mb-1 flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'border border-transparent text-foreground/80 shadow-none hover:bg-black/5 hover:text-foreground dark:hover:bg-white/5',
'sidebar-nav-text flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
sidebarCollapsed && 'justify-center px-0',
)}
>

View File

@@ -989,7 +989,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
setSkillPickerOpen(false);
setModelPickerOpen((open) => !open);
}}
disabled={sending || !currentAgent || !!switchingModelRef}
disabled={inputDisabled || sending || !currentAgent || !!switchingModelRef}
title={t('composer.pickModel')}
>
{switchingModelRef ? (

View File

@@ -1676,16 +1676,17 @@ export const useChatStore = create<ChatState>((set, get) => ({
//
// NOTE: The OpenClaw Gateway does NOT expose a sessions.delete (or equivalent)
// RPC — confirmed by inspecting client.ts, protocol.ts and the full codebase.
// Deletion is therefore a local-only UI operation: the session is removed from
// the sidebar list and its labels/activity maps are cleared. The underlying
// JSONL history file on disk is intentionally left intact, consistent with the
// newSession() design that avoids sessions.reset to preserve history.
// Deletion is therefore performed locally: the renderer drops the session
// from the sidebar / labels / activity maps and the Main process hard-deletes
// the on-disk transcript so it stops appearing in sessions.list and stops
// contributing to the Dashboard token-usage history.
deleteSession: async (key: string) => {
clearCachedSessionHistory(key);
// Soft-delete the session's JSONL transcript on disk.
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
// sessions.list skips it automatically.
// Hard-delete the session's JSONL transcript on disk.
// The main process unlinks <id>.jsonl plus any leftover
// <id>.deleted.jsonl and <id>.jsonl.reset.* siblings, then removes the
// entry from sessions.json so sessions.list stops surfacing it.
try {
const result = await hostApiFetch<{
success: boolean;

View File

@@ -200,15 +200,16 @@ export function createSessionActions(
//
// NOTE: The OpenClaw Gateway does NOT expose a sessions.delete (or equivalent)
// RPC — confirmed by inspecting client.ts, protocol.ts and the full codebase.
// Deletion is therefore a local-only UI operation: the session is removed from
// the sidebar list and its labels/activity maps are cleared. The underlying
// JSONL history file on disk is intentionally left intact, consistent with the
// newSession() design that avoids sessions.reset to preserve history.
// Deletion is therefore performed locally: the renderer drops the session
// from the sidebar / labels / activity maps and the Main process hard-deletes
// the on-disk transcript so it stops appearing in sessions.list and stops
// contributing to the Dashboard token-usage history.
deleteSession: async (key: string) => {
// Soft-delete the session's JSONL transcript on disk.
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
// sessions.list skips it automatically.
// Hard-delete the session's JSONL transcript on disk.
// The main process unlinks <id>.jsonl plus any leftover
// <id>.deleted.jsonl and <id>.jsonl.reset.* siblings, then removes the
// entry from sessions.json so sessions.list stops surfacing it.
try {
const result = await invokeIpc('session:delete', key) as {
success: boolean;

View File

@@ -229,10 +229,45 @@ describe('ChatInput agent targeting', () => {
channelTypes: [],
},
];
agentsState.defaultModelRef = 'custom-aaaaaaaa/gpt-a';
const now = '2025-01-01T00:00:00.000Z';
providersState.accounts = [
{
id: 'aaaaaaaa',
vendorId: 'custom',
label: 'Alpha',
authMode: 'api_key',
baseUrl: 'http://127.0.0.1:1/v1',
model: 'custom-aaaaaaaa/gpt-a',
enabled: true,
isDefault: true,
createdAt: now,
updatedAt: now,
},
{
id: 'bbbbbbbb',
vendorId: 'custom',
label: 'Beta',
authMode: 'api_key',
baseUrl: 'http://127.0.0.1:2/v1',
model: 'custom-bbbbbbbb/gpt-b',
enabled: true,
isDefault: false,
createdAt: now,
updatedAt: now,
},
];
providersState.statuses = [
{ id: 'aaaaaaaa', name: 'Alpha', type: 'custom', hasKey: true, keyMasked: 'sk-***', enabled: true, createdAt: now, updatedAt: now },
{ id: 'bbbbbbbb', name: 'Beta', type: 'custom', hasKey: true, keyMasked: 'sk-***', enabled: true, createdAt: now, updatedAt: now },
];
providersState.defaultAccountId = 'aaaaaaaa';
renderChatInput();
expect(screen.getByTestId('chat-composer-input')).toBeDisabled();
expect(screen.getByTestId('chat-composer-skill')).toBeDisabled();
expect(screen.getByTestId('chat-model-picker-button')).toBeDisabled();
});
it('shows starting status while gateway is running but not yet ready', () => {

View File

@@ -0,0 +1,454 @@
/**
* Unit tests for the /api/sessions/delete HTTP route.
*
* The route hard-deletes a conversation's transcript on disk:
* - <id>.jsonl — the live transcript
* - <id>.deleted.jsonl — leftovers from earlier soft-delete releases
* - <id>.jsonl.reset.* — reset snapshots from sessions.reset
* It also removes the entry from sessions.json.
*
* These tests exercise the real `handleSessionRoutes` against a temp
* OpenClaw config directory so the FS contract is verified end-to-end.
*/
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IncomingMessage, ServerResponse } from 'http';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const sendJsonMock = vi.fn();
const parseJsonBodyMock = vi.fn();
const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'session-delete-route-openclaw');
vi.mock('@electron/api/route-utils', () => ({
parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args),
sendJson: (...args: unknown[]) => sendJsonMock(...args),
}));
vi.mock('@electron/utils/paths', () => ({
getOpenClawConfigDir: () => testOpenClawConfigDir,
getOpenClawDir: () => testOpenClawConfigDir,
getOpenClawResolvedDir: () => testOpenClawConfigDir,
}));
const AGENT_ID = 'main';
const SESSIONS_DIR = join(testOpenClawConfigDir, 'agents', AGENT_ID, 'sessions');
const SESSIONS_JSON = join(SESSIONS_DIR, 'sessions.json');
function seedSessionsDir(): void {
rmSync(testOpenClawConfigDir, { recursive: true, force: true });
mkdirSync(SESSIONS_DIR, { recursive: true });
}
function writeSessionsJson(payload: Record<string, unknown>): void {
writeFileSync(SESSIONS_JSON, JSON.stringify(payload, null, 2), 'utf8');
}
function makeReq(method = 'POST'): IncomingMessage {
return { method } as IncomingMessage;
}
function makeRes(): ServerResponse {
return {
setHeader: vi.fn(),
end: vi.fn(),
} as unknown as ServerResponse;
}
const DELETE_URL = new URL('http://127.0.0.1:13210/api/sessions/delete');
const ctx = {} as never;
describe('handleSessionRoutes — POST /api/sessions/delete', () => {
beforeEach(() => {
vi.resetAllMocks();
seedSessionsDir();
});
afterAll(() => {
rmSync(testOpenClawConfigDir, { recursive: true, force: true });
});
it('hard-deletes the live <id>.jsonl and clears the entry from sessions.json', async () => {
const sessionKey = 'agent:main:session-aaa';
const fileName = 'aaa-uuid.jsonl';
writeFileSync(join(SESSIONS_DIR, fileName), 'message\n', 'utf8');
writeSessionsJson({
[sessionKey]: { sessionFile: join(SESSIONS_DIR, fileName), sessionId: 'aaa-uuid' },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
const handled = await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(handled).toBe(true);
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true });
expect(existsSync(join(SESSIONS_DIR, fileName))).toBe(false);
const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8'));
expect(updated[sessionKey]).toBeUndefined();
});
it('also removes a leftover <id>.deleted.jsonl from a prior soft-delete release', async () => {
const sessionKey = 'agent:main:session-bbb';
const baseId = 'bbb-uuid';
writeFileSync(join(SESSIONS_DIR, `${baseId}.jsonl`), '', 'utf8');
writeFileSync(join(SESSIONS_DIR, `${baseId}.deleted.jsonl`), '', 'utf8');
writeSessionsJson({
[sessionKey]: { sessionFile: join(SESSIONS_DIR, `${baseId}.jsonl`), sessionId: baseId },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(existsSync(join(SESSIONS_DIR, `${baseId}.jsonl`))).toBe(false);
expect(existsSync(join(SESSIONS_DIR, `${baseId}.deleted.jsonl`))).toBe(false);
});
it('removes every <id>.jsonl.reset.* sibling that belongs to the same session id', async () => {
const sessionKey = 'agent:main:session-ccc';
const baseId = 'ccc-uuid';
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
const reset1 = join(SESSIONS_DIR, `${baseId}.jsonl.reset.2026-04-01T00-00-00.000Z`);
const reset2 = join(SESSIONS_DIR, `${baseId}.jsonl.reset.2026-04-02T00-00-00.000Z`);
writeFileSync(liveFile, '', 'utf8');
writeFileSync(reset1, '', 'utf8');
writeFileSync(reset2, '', 'utf8');
writeSessionsJson({
[sessionKey]: { sessionFile: liveFile, sessionId: baseId },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(existsSync(liveFile)).toBe(false);
expect(existsSync(reset1)).toBe(false);
expect(existsSync(reset2)).toBe(false);
});
it('still succeeds and updates sessions.json when the transcript is already gone', async () => {
const sessionKey = 'agent:main:session-ddd';
const baseId = 'ddd-uuid';
// No transcript file on disk — only sessions.json knows about it.
writeSessionsJson({
[sessionKey]: { sessionFile: join(SESSIONS_DIR, `${baseId}.jsonl`), sessionId: baseId },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true });
const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8'));
expect(updated[sessionKey]).toBeUndefined();
});
it('rejects sessionKeys that are not agent-scoped with 400', async () => {
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: 'main' });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
400,
expect.objectContaining({ success: false }),
);
});
it('rejects agentIds that contain path-traversal segments with 400', async () => {
// Even if the caller manages to put `..` into the agent slot, the route
// must refuse before any FS access happens — otherwise sessionsDir would
// resolve outside ~/.openclaw/agents/.
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: 'agent:..:foo' });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
400,
expect.objectContaining({ success: false }),
);
});
it('refuses to unlink files when sessionFile points outside the agent sessions dir', async () => {
// Defence-in-depth: if a corrupt sessions.json claims the transcript
// lives in /tmp (or anywhere outside the agent sessions folder), the
// sweep must not run there and existing files must survive untouched.
const sessionKey = 'agent:main:session-escape';
const escapeDir = join(testOpenClawConfigDir, 'unrelated-dir');
mkdirSync(escapeDir, { recursive: true });
const escapeFile = join(escapeDir, 'escape-uuid.jsonl');
writeFileSync(escapeFile, 'must-not-be-deleted', 'utf8');
writeSessionsJson({
[sessionKey]: { sessionFile: escapeFile, sessionId: 'escape-uuid' },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
400,
expect.objectContaining({ success: false }),
);
expect(existsSync(escapeFile)).toBe(true);
expect(readFileSync(escapeFile, 'utf8')).toBe('must-not-be-deleted');
// sessions.json is left intact when the resolution fails — the entry
// stays so a follow-up fix can be applied without losing track of it.
const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8'));
expect(updated[sessionKey]).toBeDefined();
});
it("also sweeps OpenClaw's trajectory sidecars (<id>.trajectory.jsonl + <id>.trajectory-path.json)", async () => {
// OpenClaw writes `<base>.trajectory.jsonl` (flight recorder) and
// `<base>.trajectory-path.json` (pointer) next to the session file.
// Hard-deleting the conversation must leave neither behind, otherwise
// the next `sessions.list` is clean but the agent's sessions/ folder
// accumulates orphaned trajectory data.
const sessionKey = 'agent:main:session-traj';
const baseId = 'traj-uuid';
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
const trajFile = join(SESSIONS_DIR, `${baseId}.trajectory.jsonl`);
const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`);
writeFileSync(liveFile, '', 'utf8');
writeFileSync(trajFile, '{"event":"session.started"}\n', 'utf8');
writeFileSync(
pointerFile,
JSON.stringify({
traceSchema: 'openclaw-trajectory-pointer',
schemaVersion: 1,
sessionId: baseId,
runtimeFile: trajFile,
}),
'utf8',
);
writeSessionsJson({
[sessionKey]: { sessionFile: liveFile, sessionId: baseId },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(existsSync(liveFile)).toBe(false);
expect(existsSync(trajFile)).toBe(false);
expect(existsSync(pointerFile)).toBe(false);
});
it('follows the trajectory pointer to unlink an OPENCLAW_TRAJECTORY_DIR-style off-sessions runtime file', async () => {
// When OPENCLAW_TRAJECTORY_DIR is set, the pointer's `runtimeFile`
// resolves outside sessions/. Without the pointer-follow, that file
// would be orphaned forever after deletion.
const sessionKey = 'agent:main:session-trajdir';
const baseId = 'trajdir-uuid';
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`);
const trajectoryDir = join(testOpenClawConfigDir, 'trajectory-dir');
mkdirSync(trajectoryDir, { recursive: true });
const offDiskRuntime = join(trajectoryDir, `${baseId}.jsonl`);
writeFileSync(liveFile, '', 'utf8');
writeFileSync(offDiskRuntime, '{"event":"prompt.submitted"}\n', 'utf8');
writeFileSync(
pointerFile,
JSON.stringify({
traceSchema: 'openclaw-trajectory-pointer',
schemaVersion: 1,
sessionId: baseId,
runtimeFile: offDiskRuntime,
}),
'utf8',
);
writeSessionsJson({
[sessionKey]: { sessionFile: liveFile, sessionId: baseId },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(existsSync(liveFile)).toBe(false);
expect(existsSync(pointerFile)).toBe(false);
expect(existsSync(offDiskRuntime)).toBe(false);
});
it('refuses to follow a pointer whose runtimeFile is not an absolute .jsonl path', async () => {
// Defence-in-depth: a malformed/hostile pointer must NOT get us to
// unlink arbitrary files (e.g. /etc/passwd or relative paths).
const sessionKey = 'agent:main:session-evilpointer';
const baseId = 'evilpointer-uuid';
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`);
const bystander = join(testOpenClawConfigDir, 'bystander-dir', 'must-not-touch.txt');
mkdirSync(join(testOpenClawConfigDir, 'bystander-dir'), { recursive: true });
writeFileSync(bystander, 'kept', 'utf8');
writeFileSync(liveFile, '', 'utf8');
writeFileSync(
pointerFile,
JSON.stringify({
traceSchema: 'openclaw-trajectory-pointer',
schemaVersion: 1,
sessionId: baseId,
// Wrong extension on purpose — not a `.jsonl`.
runtimeFile: bystander,
}),
'utf8',
);
writeSessionsJson({
[sessionKey]: { sessionFile: liveFile, sessionId: baseId },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
// The session and the (correctly-detected) pointer are gone, but the
// bystander file the pointer tried to escape to is untouched.
expect(existsSync(liveFile)).toBe(false);
expect(existsSync(pointerFile)).toBe(false);
expect(existsSync(bystander)).toBe(true);
expect(readFileSync(bystander, 'utf8')).toBe('kept');
});
it("tolerates a malformed pointer (still cleans local sidecars, doesn't fail the whole delete)", async () => {
const sessionKey = 'agent:main:session-malformedptr';
const baseId = 'malformedptr-uuid';
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
const trajFile = join(SESSIONS_DIR, `${baseId}.trajectory.jsonl`);
const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`);
writeFileSync(liveFile, '', 'utf8');
writeFileSync(trajFile, '', 'utf8');
// Garbage JSON — the sweep must not blow up.
writeFileSync(pointerFile, '{ not-json', 'utf8');
writeSessionsJson({
[sessionKey]: { sessionFile: liveFile, sessionId: baseId },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true });
expect(existsSync(liveFile)).toBe(false);
expect(existsSync(trajFile)).toBe(false);
expect(existsSync(pointerFile)).toBe(false);
});
it("does not touch another session's trajectory sidecars during the sweep", async () => {
const targetKey = 'agent:main:session-trajiso-target';
const survivorKey = 'agent:main:session-trajiso-keep';
const targetBase = 'trajiso-target';
const survivorBase = 'trajiso-keep';
const targetFile = join(SESSIONS_DIR, `${targetBase}.jsonl`);
const targetTraj = join(SESSIONS_DIR, `${targetBase}.trajectory.jsonl`);
const survivorFile = join(SESSIONS_DIR, `${survivorBase}.jsonl`);
const survivorTraj = join(SESSIONS_DIR, `${survivorBase}.trajectory.jsonl`);
const survivorPointer = join(SESSIONS_DIR, `${survivorBase}.trajectory-path.json`);
writeFileSync(targetFile, '', 'utf8');
writeFileSync(targetTraj, '', 'utf8');
writeFileSync(survivorFile, 'kept', 'utf8');
writeFileSync(survivorTraj, 'kept', 'utf8');
writeFileSync(survivorPointer, JSON.stringify({
traceSchema: 'openclaw-trajectory-pointer',
schemaVersion: 1,
sessionId: survivorBase,
runtimeFile: survivorTraj,
}), 'utf8');
writeSessionsJson({
[targetKey]: { sessionFile: targetFile, sessionId: targetBase },
[survivorKey]: { sessionFile: survivorFile, sessionId: survivorBase },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: targetKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(existsSync(targetFile)).toBe(false);
expect(existsSync(targetTraj)).toBe(false);
expect(existsSync(survivorFile)).toBe(true);
expect(existsSync(survivorTraj)).toBe(true);
expect(existsSync(survivorPointer)).toBe(true);
});
it('treats Windows forward-slash absolute paths as absolute (cross-platform)', async () => {
// OpenClaw on Windows can write `sessionFile` as either `C:\…` (back-
// slash) or `C:/…` (forward-slash). Node's `path.win32.isAbsolute`
// accepts both; the resolver must too. We can't actually create a
// `C:/…` path on POSIX, so we cover the same code path with a Windows-
// style absolute that points back into our temp sessions dir using
// mixed slashes. The detector should still treat it as absolute and
// route through the in-scope sibling sweep.
const sessionKey = 'agent:main:session-win';
const baseId = 'win-uuid';
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
writeFileSync(liveFile, '', 'utf8');
// Force forward slashes — historically this would have been classed as
// a *relative* filename on POSIX and `join`ed onto sessionsDir, which
// produced a junk path that no `readdir` could find.
const forwardSlashAbs = liveFile.replace(/\\/g, '/');
writeSessionsJson({
[sessionKey]: { sessionFile: forwardSlashAbs, sessionId: baseId },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true });
expect(existsSync(liveFile)).toBe(false);
});
it('does not touch other sessions in the same directory', async () => {
const targetKey = 'agent:main:session-eee';
const survivorKey = 'agent:main:session-fff';
const targetBase = 'eee-uuid';
const survivorBase = 'fff-uuid';
const targetFile = join(SESSIONS_DIR, `${targetBase}.jsonl`);
const survivorFile = join(SESSIONS_DIR, `${survivorBase}.jsonl`);
writeFileSync(targetFile, '', 'utf8');
writeFileSync(survivorFile, 'kept', 'utf8');
writeSessionsJson({
[targetKey]: { sessionFile: targetFile, sessionId: targetBase },
[survivorKey]: { sessionFile: survivorFile, sessionId: survivorBase },
});
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: targetKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(existsSync(targetFile)).toBe(false);
expect(existsSync(survivorFile)).toBe(true);
expect(readFileSync(survivorFile, 'utf8')).toBe('kept');
const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8'));
expect(updated[targetKey]).toBeUndefined();
expect(updated[survivorKey]).toBeDefined();
});
it('also supports the array-shape sessions.json (sessions[] with id field)', async () => {
const sessionKey = 'agent:main:session-ggg';
const baseId = 'ggg-uuid';
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
writeFileSync(liveFile, '', 'utf8');
writeSessionsJson({
sessions: [
{ key: sessionKey, id: baseId },
{ key: 'agent:main:keep', id: 'keep-uuid' },
],
});
writeFileSync(join(SESSIONS_DIR, 'keep-uuid.jsonl'), 'kept', 'utf8');
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
expect(existsSync(liveFile)).toBe(false);
expect(existsSync(join(SESSIONS_DIR, 'keep-uuid.jsonl'))).toBe(true);
const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8')) as { sessions: Array<{ key: string }> };
expect(updated.sessions.find((s) => s.key === sessionKey)).toBeUndefined();
expect(updated.sessions.find((s) => s.key === 'agent:main:keep')).toBeDefined();
});
});