diff --git a/AGENTS.md b/AGENTS.md index 74e585e..98578ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `.jsonl` plus any leftover `.deleted.jsonl` and `.jsonl.reset.*` siblings, *and* OpenClaw's trajectory artefacts (`.trajectory.jsonl` flight recorder + `.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. diff --git a/electron/api/routes/sessions.ts b/electron/api/routes/sessions.ts index 0ef4787..bdd92aa 100644 --- a/electron/api/routes/sessions.ts +++ b/electron/api/routes/sessions.ts @@ -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 + // (`.trajectory.jsonl` + `.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; - let uuidFileName: string | undefined; - let resolvedSrcPath: string | undefined; - if (Array.isArray(sessionsJson.sessions)) { - const entry = (sessionsJson.sessions as Array>) - .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; - 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; - if (Array.isArray(json2.sessions)) { - json2.sessions = (json2.sessions as Array>) - .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) { diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 4edb4eb..8b8e0ec 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -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::" — e.g. "agent:main:session-1234567890". - * The JSONL file lives at: ~/.openclaw/agents//sessions/.jsonl - * Renaming to .deleted.jsonl hides it from sessions.list. + * The JSONL file lives at: ~/.openclaw/agents//sessions/.jsonl + * (where is typically a UUID resolved via sessions.json). + * + * For each deleted session we unlink every file that belongs to its on-disk id: + * - .jsonl — the live transcript + * - .deleted.jsonl — leftovers from earlier soft-delete releases + * - .jsonl.reset.* — historical snapshots produced by sessions.reset + * - .trajectory.jsonl — OpenClaw runtime "flight recorder" sidecar + * - .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>) - .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; - // 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; - - if (Array.isArray(json2.sessions)) { - json2.sessions = (json2.sessions as Array>) - .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 }; diff --git a/electron/utils/session-files.ts b/electron/utils/session-files.ts new file mode 100644 index 0000000..87e17f3 --- /dev/null +++ b/electron/utils/session-files.ts @@ -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: + * - `.jsonl` the live transcript + * - `.deleted.jsonl` legacy soft-delete leftover + * - `.jsonl.reset.*` reset snapshots from sessions.reset + * - `.trajectory.jsonl` OpenClaw runtime "flight recorder" + * (default location, beside session) + * - `.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, + sessionsDir: string, + sessionKey: string, +): SessionResolutionResult { + let uuidFileName: string | undefined; + let resolvedSrcPath: string | undefined; + + if (Array.isArray(sessionsJson.sessions)) { + const entry = (sessionsJson.sessions as Array>) + .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; + 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 { + 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; + 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 { + 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, + sessionKey: string, +): void { + if (Array.isArray(sessionsJson.sessions)) { + sessionsJson.sessions = (sessionsJson.sessions as Array>) + .filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey); + } else if (sessionsJson[sessionKey]) { + delete sessionsJson[sessionKey]; + } +} diff --git a/harness/specs/tasks/hard-delete-session-jsonl.md b/harness/specs/tasks/hard-delete-session-jsonl.md new file mode 100644 index 0000000..78499c4 --- /dev/null +++ b/harness/specs/tasks/hard-delete-session-jsonl.md @@ -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 .jsonl, .deleted.jsonl and any .jsonl.reset.* siblings from the agent's sessions folder. + - OpenClaw's trajectory artefacts for the same session id are removed too — both the local .trajectory.jsonl flight recorder and the .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 .trajectory.jsonl and .trajectory-path.json sidecars produced by OpenClaw's runtime trajectory writer. + - When .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 `.jsonl` transcript was renamed to +`.deleted.jsonl` so `sessions.list` would skip it. This task replaces +that rename with a true `unlink` plus a sibling sweep that also removes +`.deleted.jsonl` (legacy soft-delete leftovers) and `.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. diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b01e426..a7ba201 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -290,7 +290,10 @@ export function Sidebar() {