diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e6b5d9b..a13a4b02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Security/Sandbox: confine mirrored skill sync destinations to the sandbox `skills/` root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal. - Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip `toolResult.details` from model-facing transcript/compaction inputs to reduce prompt-injection replay risk. - Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (`429` + `Retry-After`). Thanks @akhmittra. +- Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra. - Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini. - Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini. - Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini. diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index f5a0444d3..2bca43901 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -6,6 +6,7 @@ import { loadSessionStore, resolveAgentIdFromSessionKey, resolveMainSessionKey, + resolveSessionFilePath, resolveStorePath, } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; @@ -229,8 +230,16 @@ async function buildSubagentStatsLine(params: { }); const sessionId = entry?.sessionId; - const transcriptPath = - sessionId && storePath ? path.join(path.dirname(storePath), `${sessionId}.jsonl`) : undefined; + let transcriptPath: string | undefined; + if (sessionId && storePath) { + try { + transcriptPath = resolveSessionFilePath(sessionId, entry, { + sessionsDir: path.dirname(storePath), + }); + } catch { + transcriptPath = undefined; + } + } const input = entry?.inputTokens; const output = entry?.outputTokens; diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 41b768154..e98be654f 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import path from "node:path"; import type { AnyAgentTool } from "./common.js"; import { loadConfig } from "../../config/config.js"; +import { resolveSessionFilePath } from "../../config/sessions.js"; import { callGateway } from "../../gateway/call.js"; import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { jsonResult, readStringArrayParam } from "./common.js"; @@ -152,10 +153,20 @@ export function createSessionsListTool(opts?: { }); const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined; - const transcriptPath = - sessionId && storePath - ? path.join(path.dirname(storePath), `${sessionId}.jsonl`) - : undefined; + const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile; + const sessionFile = typeof sessionFileRaw === "string" ? sessionFileRaw : undefined; + let transcriptPath: string | undefined; + if (sessionId && storePath) { + try { + transcriptPath = resolveSessionFilePath( + sessionId, + sessionFile ? { sessionFile } : undefined, + { sessionsDir: path.dirname(storePath) }, + ); + } catch { + transcriptPath = undefined; + } + } const row: SessionListRow = { key: displayKey, diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 3fd4e740d..8a31e0119 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -55,10 +55,12 @@ export type SessionInitResult = { function forkSessionFromParent(params: { parentEntry: SessionEntry; + sessionsDir: string; }): { sessionId: string; sessionFile: string } | null { const parentSessionFile = resolveSessionFilePath( params.parentEntry.sessionId, params.parentEntry, + { sessionsDir: params.sessionsDir }, ); if (!parentSessionFile || !fs.existsSync(parentSessionFile)) { return null; @@ -320,6 +322,7 @@ export async function initSessionState(params: { ); const forked = forkSessionFromParent({ parentEntry: sessionStore[parentSessionKey], + sessionsDir: path.dirname(storePath), }); if (forked) { sessionId = forked.sessionId; diff --git a/src/config/sessions/paths.test.ts b/src/config/sessions/paths.test.ts index 890acff68..cdea98b2e 100644 --- a/src/config/sessions/paths.test.ts +++ b/src/config/sessions/paths.test.ts @@ -1,6 +1,12 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveStorePath } from "./paths.js"; +import { + resolveSessionFilePath, + resolveSessionTranscriptPath, + resolveSessionTranscriptPathInDir, + resolveStorePath, + validateSessionId, +} from "./paths.js"; describe("resolveStorePath", () => { afterEach(() => { @@ -20,3 +26,53 @@ describe("resolveStorePath", () => { ); }); }); + +describe("session path safety", () => { + it("validates safe session IDs", () => { + expect(validateSessionId("sess-1")).toBe("sess-1"); + expect(validateSessionId("ABC_123.hello")).toBe("ABC_123.hello"); + }); + + it("rejects unsafe session IDs", () => { + expect(() => validateSessionId("../etc/passwd")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("a/b")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("a\\b")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("/abs")).toThrow(/Invalid session ID/); + }); + + it("resolves transcript path inside an explicit sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + const resolved = resolveSessionTranscriptPathInDir("sess-1", sessionsDir, "topic/a+b"); + + expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl")); + }); + + it("rejects unsafe sessionFile candidates that escape the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + expect(() => + resolveSessionFilePath("sess-1", { sessionFile: "../../etc/passwd" }, { sessionsDir }), + ).toThrow(/within sessions directory/); + + expect(() => + resolveSessionFilePath("sess-1", { sessionFile: "/etc/passwd" }, { sessionsDir }), + ).toThrow(/within sessions directory/); + }); + + it("accepts sessionFile candidates within the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: "subdir/threaded-session.jsonl" }, + { sessionsDir }, + ); + + expect(resolved).toBe(path.resolve(sessionsDir, "subdir/threaded-session.jsonl")); + }); + + it("uses agent sessions dir fallback for transcript path", () => { + const resolved = resolveSessionTranscriptPath("sess-1", "main"); + expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true); + }); +}); diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 73491270e..9801f9a6b 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -1,6 +1,5 @@ import os from "node:os"; import path from "node:path"; -import type { SessionEntry } from "./types.js"; import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; import { resolveStateDir } from "../paths.js"; @@ -34,11 +33,44 @@ export function resolveDefaultSessionStorePath(agentId?: string): string { return path.join(resolveAgentSessionsDir(agentId), "sessions.json"); } -export function resolveSessionTranscriptPath( +export const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i; + +export function validateSessionId(sessionId: string): string { + const trimmed = sessionId.trim(); + if (!SAFE_SESSION_ID_RE.test(trimmed)) { + throw new Error(`Invalid session ID: ${sessionId}`); + } + return trimmed; +} + +function resolveSessionsDir(opts?: { agentId?: string; sessionsDir?: string }): string { + const sessionsDir = opts?.sessionsDir?.trim(); + if (sessionsDir) { + return path.resolve(sessionsDir); + } + return resolveAgentSessionsDir(opts?.agentId); +} + +function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): string { + const trimmed = candidate.trim(); + if (!trimmed) { + throw new Error("Session file path must not be empty"); + } + const resolvedBase = path.resolve(sessionsDir); + const resolvedCandidate = path.resolve(resolvedBase, trimmed); + const relative = path.relative(resolvedBase, resolvedCandidate); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Session file path must be within sessions directory"); + } + return resolvedCandidate; +} + +export function resolveSessionTranscriptPathInDir( sessionId: string, - agentId?: string, + sessionsDir: string, topicId?: string | number, ): string { + const safeSessionId = validateSessionId(sessionId); const safeTopicId = typeof topicId === "string" ? encodeURIComponent(topicId) @@ -46,17 +78,31 @@ export function resolveSessionTranscriptPath( ? String(topicId) : undefined; const fileName = - safeTopicId !== undefined ? `${sessionId}-topic-${safeTopicId}.jsonl` : `${sessionId}.jsonl`; - return path.join(resolveAgentSessionsDir(agentId), fileName); + safeTopicId !== undefined + ? `${safeSessionId}-topic-${safeTopicId}.jsonl` + : `${safeSessionId}.jsonl`; + return resolvePathWithinSessionsDir(sessionsDir, fileName); +} + +export function resolveSessionTranscriptPath( + sessionId: string, + agentId?: string, + topicId?: string | number, +): string { + return resolveSessionTranscriptPathInDir(sessionId, resolveAgentSessionsDir(agentId), topicId); } export function resolveSessionFilePath( sessionId: string, - entry?: SessionEntry, - opts?: { agentId?: string }, + entry?: { sessionFile?: string }, + opts?: { agentId?: string; sessionsDir?: string }, ): string { + const sessionsDir = resolveSessionsDir(opts); const candidate = entry?.sessionFile?.trim(); - return candidate ? candidate : resolveSessionTranscriptPath(sessionId, opts?.agentId); + if (candidate) { + return resolvePathWithinSessionsDir(sessionsDir, candidate); + } + return resolveSessionTranscriptPathInDir(sessionId, sessionsDir); } export function resolveStorePath(store?: string, opts?: { agentId?: string }) { diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index 593548db7..dabed46c8 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import type { SessionEntry } from "./types.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; -import { resolveDefaultSessionStorePath, resolveSessionTranscriptPath } from "./paths.js"; +import { resolveDefaultSessionStorePath, resolveSessionFilePath } from "./paths.js"; import { loadSessionStore, updateSessionStore } from "./store.js"; function stripQuery(value: string): string { @@ -103,8 +103,17 @@ export async function appendAssistantMessageToSessionTranscript(params: { return { ok: false, reason: `unknown sessionKey: ${sessionKey}` }; } - const sessionFile = - entry.sessionFile?.trim() || resolveSessionTranscriptPath(entry.sessionId, params.agentId); + let sessionFile: string; + try { + sessionFile = resolveSessionFilePath(entry.sessionId, entry, { + sessionsDir: path.dirname(storePath), + }); + } catch (err) { + return { + ok: false, + reason: err instanceof Error ? err.message : String(err), + }; + } await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId }); diff --git a/src/gateway/server-methods/chat.inject.parentid.test.ts b/src/gateway/server-methods/chat.inject.parentid.test.ts index a8cf43d15..532be67eb 100644 --- a/src/gateway/server-methods/chat.inject.parentid.test.ts +++ b/src/gateway/server-methods/chat.inject.parentid.test.ts @@ -30,7 +30,7 @@ describe("gateway chat.inject transcript writes", () => { return { ...original, loadSessionEntry: () => ({ - storePath: "/tmp/store.json", + storePath: path.join(dir, "sessions.json"), entry: { sessionId: "sess-1", sessionFile: transcriptPath, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index d19d98072..28ea99b60 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -9,6 +9,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; +import { resolveSessionFilePath } from "../../config/sessions.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { @@ -54,13 +55,19 @@ function resolveTranscriptPath(params: { sessionFile?: string; }): string | null { const { sessionId, storePath, sessionFile } = params; - if (sessionFile) { - return sessionFile; - } - if (!storePath) { + if (!storePath && !sessionFile) { + return null; + } + try { + const sessionsDir = storePath ? path.dirname(storePath) : undefined; + return resolveSessionFilePath( + sessionId, + sessionFile ? { sessionFile } : undefined, + sessionsDir ? { sessionsDir } : undefined, + ); + } catch { return null; } - return path.join(path.dirname(storePath), `${sessionId}.jsonl`); } function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): { diff --git a/src/gateway/server-methods/usage.sessions-usage.test.ts b/src/gateway/server-methods/usage.sessions-usage.test.ts index 1df850327..6f5c62ab7 100644 --- a/src/gateway/server-methods/usage.sessions-usage.test.ts +++ b/src/gateway/server-methods/usage.sessions-usage.test.ts @@ -107,40 +107,73 @@ describe("sessions.usage", () => { it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => { const storeKey = "agent:opus:slack:dm:u123"; - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-")); - const sessionFile = path.join(tempDir, "s-opus.jsonl"); - fs.writeFileSync(sessionFile, "", "utf-8"); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-")); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + + try { + const agentSessionsDir = path.join(stateDir, "agents", "opus", "sessions"); + fs.mkdirSync(agentSessionsDir, { recursive: true }); + const sessionFile = path.join(agentSessionsDir, "s-opus.jsonl"); + fs.writeFileSync(sessionFile, "", "utf-8"); + const respond = vi.fn(); + + // Swap the store mock for this test: the canonical key differs from the discovered key + // but points at the same sessionId. + vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({ + storePath: "(multiple)", + store: { + [storeKey]: { + sessionId: "s-opus", + sessionFile: "s-opus.jsonl", + label: "Named session", + updatedAt: 999, + }, + }, + }); + + // Query via discovered key: agent:: + await usageHandlers["sessions.usage"]({ + respond, + params: { + startDate: "2026-02-01", + endDate: "2026-02-02", + key: "agent:opus:s-opus", + limit: 10, + }, + } as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]); + + expect(respond).toHaveBeenCalledTimes(1); + expect(respond.mock.calls[0]?.[0]).toBe(true); + const result = respond.mock.calls[0]?.[1] as unknown as { sessions: Array<{ key: string }> }; + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]?.key).toBe(storeKey); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("rejects traversal-style keys in specific session usage lookups", async () => { const respond = vi.fn(); - // Swap the store mock for this test: the canonical key differs from the discovered key - // but points at the same sessionId. - vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({ - storePath: "(multiple)", - store: { - [storeKey]: { - sessionId: "s-opus", - sessionFile, - label: "Named session", - updatedAt: 999, - }, - }, - }); - - // Query via discovered key: agent:: await usageHandlers["sessions.usage"]({ respond, params: { startDate: "2026-02-01", endDate: "2026-02-02", - key: "agent:opus:s-opus", + key: "agent:opus:../../etc/passwd", limit: 10, }, } as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]); expect(respond).toHaveBeenCalledTimes(1); - expect(respond.mock.calls[0]?.[0]).toBe(true); - const result = respond.mock.calls[0]?.[1] as unknown as { sessions: Array<{ key: string }> }; - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]?.key).toBe(storeKey); + expect(respond.mock.calls[0]?.[0]).toBe(false); + const error = respond.mock.calls[0]?.[2] as { message?: string } | undefined; + expect(error?.message).toContain("Invalid session reference"); }); }); diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 14c2b39eb..fefa103b1 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import path from "node:path"; import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js"; import type { CostUsageSummary, @@ -291,7 +292,7 @@ export const usageHandlers: GatewayRequestHandlers = { const specificKey = typeof p.key === "string" ? p.key.trim() : null; // Load session store for named sessions - const { store } = loadCombinedSessionStoreForGateway(config); + const { storePath, store } = loadCombinedSessionStoreForGateway(config); const now = Date.now(); // Merge discovered sessions with store entries @@ -331,9 +332,21 @@ export const usageHandlers: GatewayRequestHandlers = { const sessionId = storeEntry?.sessionId ?? keyRest; // Resolve the session file path - const sessionFile = resolveSessionFilePath(sessionId, storeEntry, { - agentId: agentIdFromKey, - }); + let sessionFile: string; + try { + const pathOpts = + storePath && storePath !== "(multiple)" + ? { sessionsDir: path.dirname(storePath) } + : { agentId: agentIdFromKey }; + sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts); + } catch { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session reference: ${specificKey}`), + ); + return; + } try { const stats = fs.statSync(sessionFile); @@ -756,15 +769,25 @@ export const usageHandlers: GatewayRequestHandlers = { } const config = loadConfig(); - const { entry } = loadSessionEntry(key); + const { entry, storePath } = loadSessionEntry(key); // For discovered sessions (not in store), try using key as sessionId directly const parsed = parseAgentSessionKey(key); const agentId = parsed?.agentId; const rawSessionId = parsed?.rest ?? key; const sessionId = entry?.sessionId ?? rawSessionId; - const sessionFile = - entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId }); + let sessionFile: string; + try { + const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId }; + sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts); + } catch { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session key: ${key}`), + ); + return; + } const timeseries = await loadSessionUsageTimeSeries({ sessionId, @@ -798,15 +821,25 @@ export const usageHandlers: GatewayRequestHandlers = { : 200; const config = loadConfig(); - const { entry } = loadSessionEntry(key); + const { entry, storePath } = loadSessionEntry(key); // For discovered sessions (not in store), try using key as sessionId directly const parsed = parseAgentSessionKey(key); const agentId = parsed?.agentId; const rawSessionId = parsed?.rest ?? key; const sessionId = entry?.sessionId ?? rawSessionId; - const sessionFile = - entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId }); + let sessionFile: string; + try { + const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId }; + sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts); + } catch { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session key: ${key}`), + ); + return; + } const { loadSessionLogs } = await import("../../infra/session-cost-usage.js"); const logs = await loadSessionLogs({ diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index e465999b1..3bdc1919d 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -507,3 +507,26 @@ describe("resolveSessionTranscriptCandidates", () => { ); }); }); + +describe("resolveSessionTranscriptCandidates safety", () => { + test("drops unsafe session IDs instead of producing traversal paths", () => { + const candidates = resolveSessionTranscriptCandidates( + "../etc/passwd", + "/tmp/openclaw/agents/main/sessions/sessions.json", + ); + + expect(candidates).toEqual([]); + }); + + test("drops unsafe sessionFile candidates and keeps safe fallbacks", () => { + const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json"; + const candidates = resolveSessionTranscriptCandidates( + "sess-safe", + storePath, + "../../etc/passwd", + ); + + expect(candidates.some((value) => value.includes("etc/passwd"))).toBe(false); + expect(candidates).toContain(path.join(path.dirname(storePath), "sess-safe.jsonl")); + }); +}); diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 024ecf1ad..c43d575d5 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { SessionPreviewItem } from "./session-utils.types.js"; -import { resolveSessionTranscriptPath } from "../config/sessions.js"; +import { + resolveSessionFilePath, + resolveSessionTranscriptPath, + resolveSessionTranscriptPathInDir, +} from "../config/sessions.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js"; import { stripEnvelope } from "./chat-sanitize.js"; @@ -61,19 +65,40 @@ export function resolveSessionTranscriptCandidates( agentId?: string, ): string[] { const candidates: string[] = []; - if (sessionFile) { - candidates.push(sessionFile); - } + const pushCandidate = (resolve: () => string): void => { + try { + candidates.push(resolve()); + } catch { + // Ignore invalid paths/IDs and keep scanning other safe candidates. + } + }; + if (storePath) { - const dir = path.dirname(storePath); - candidates.push(path.join(dir, `${sessionId}.jsonl`)); + const sessionsDir = path.dirname(storePath); + if (sessionFile) { + pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir })); + } + pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir)); + } else if (sessionFile) { + if (agentId) { + pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId })); + } else { + const trimmed = sessionFile.trim(); + if (trimmed) { + candidates.push(path.resolve(trimmed)); + } + } } + if (agentId) { - candidates.push(resolveSessionTranscriptPath(sessionId, agentId)); + pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId)); } + const home = resolveRequiredHomeDir(process.env, os.homedir); - candidates.push(path.join(home, ".openclaw", "sessions", `${sessionId}.jsonl`)); - return candidates; + const legacyDir = path.join(home, ".openclaw", "sessions"); + pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir)); + + return Array.from(new Set(candidates)); } export function archiveFileOnDisk(filePath: string, reason: string): string {