diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c81c13d8c..719c9cb4148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc. +- Sessions/Paths: resolve symlinked state-dir aliases during transcript-path validation while preserving safe cross-agent/state-root compatibility for valid `agents//sessions/**` paths. (#18593) Thanks @EpaL and @vincentkoc. - Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. - Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. - Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index cd4ae0f4a92..5e66d36237d 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -561,15 +561,22 @@ describe("sessions", () => { }); }); - it("rejects absolute sessionFile paths outside agent sessions directories", () => { + it("falls back to derived transcript path when sessionFile is outside agent sessions directories", () => { withStateDir(path.resolve("/home/user/.openclaw"), () => { - expect(() => - resolveSessionFilePath( - "sess-1", - { sessionFile: path.resolve("/etc/passwd") }, - { agentId: "bot1" }, + const sessionFile = resolveSessionFilePath( + "sess-1", + { sessionFile: path.resolve("/etc/passwd") }, + { agentId: "bot1" }, + ); + expect(sessionFile).toBe( + path.join( + path.resolve("/home/user/.openclaw"), + "agents", + "bot1", + "sessions", + "sess-1.jsonl", ), - ).toThrow(/within sessions directory/); + ); }); }); diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 6144bd599b1..53e6c9c19f0 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js"; @@ -76,8 +77,10 @@ function resolvePathFromAgentSessionsDir( agentSessionsDir: string, candidateAbsPath: string, ): string | undefined { - const agentBase = path.resolve(agentSessionsDir); - const relative = path.relative(agentBase, candidateAbsPath); + const agentBase = + safeRealpathSync(path.resolve(agentSessionsDir)) ?? path.resolve(agentSessionsDir); + const realCandidate = safeRealpathSync(candidateAbsPath) ?? candidateAbsPath; + const relative = path.relative(agentBase, realCandidate); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { return undefined; } @@ -112,6 +115,14 @@ function extractAgentIdFromAbsoluteSessionPath(candidateAbsPath: string): string return agentId || undefined; } +function safeRealpathSync(filePath: string): string | undefined { + try { + return fs.realpathSync(filePath); + } catch { + return undefined; + } +} + function resolvePathWithinSessionsDir( sessionsDir: string, candidate: string, @@ -122,21 +133,28 @@ function resolvePathWithinSessionsDir( throw new Error("Session file path must not be empty"); } const resolvedBase = path.resolve(sessionsDir); + const realBase = safeRealpathSync(resolvedBase) ?? resolvedBase; // Normalize absolute paths that are within the sessions directory. // Older versions stored absolute sessionFile paths in sessions.json; // convert them to relative so the containment check passes. - const normalized = path.isAbsolute(trimmed) ? path.relative(resolvedBase, trimmed) : trimmed; - if (normalized.startsWith("..") && path.isAbsolute(trimmed)) { + const realTrimmed = path.isAbsolute(trimmed) ? (safeRealpathSync(trimmed) ?? trimmed) : trimmed; + const normalized = path.isAbsolute(realTrimmed) + ? path.relative(realBase, realTrimmed) + : realTrimmed; + if (normalized.startsWith("..") && path.isAbsolute(realTrimmed)) { const tryAgentFallback = (agentId: string): string | undefined => { const normalizedAgentId = normalizeAgentId(agentId); - const siblingSessionsDir = resolveSiblingAgentSessionsDir(resolvedBase, normalizedAgentId); + const siblingSessionsDir = resolveSiblingAgentSessionsDir(realBase, normalizedAgentId); if (siblingSessionsDir) { - const siblingResolved = resolvePathFromAgentSessionsDir(siblingSessionsDir, trimmed); + const siblingResolved = resolvePathFromAgentSessionsDir(siblingSessionsDir, realTrimmed); if (siblingResolved) { return siblingResolved; } } - return resolvePathFromAgentSessionsDir(resolveAgentSessionsDir(normalizedAgentId), trimmed); + return resolvePathFromAgentSessionsDir( + resolveAgentSessionsDir(normalizedAgentId), + realTrimmed, + ); }; const explicitAgentId = opts?.agentId?.trim(); @@ -146,7 +164,7 @@ function resolvePathWithinSessionsDir( return resolvedFromAgent; } } - const extractedAgentId = extractAgentIdFromAbsoluteSessionPath(trimmed); + const extractedAgentId = extractAgentIdFromAbsoluteSessionPath(realTrimmed); if (extractedAgentId) { const resolvedFromPath = tryAgentFallback(extractedAgentId); if (resolvedFromPath) { @@ -156,13 +174,13 @@ function resolvePathWithinSessionsDir( // Accept it even if the root directory differs from the current env // (e.g., OPENCLAW_STATE_DIR changed between session creation and resolution). // The structural pattern provides sufficient containment guarantees. - return path.resolve(trimmed); + return path.resolve(realTrimmed); } } if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) { throw new Error("Session file path must be within sessions directory"); } - return path.resolve(resolvedBase, normalized); + return path.resolve(realBase, normalized); } export function resolveSessionTranscriptPathInDir( @@ -200,7 +218,11 @@ export function resolveSessionFilePath( const sessionsDir = resolveSessionsDir(opts); const candidate = entry?.sessionFile?.trim(); if (candidate) { - return resolvePathWithinSessionsDir(sessionsDir, candidate, { agentId: opts?.agentId }); + try { + return resolvePathWithinSessionsDir(sessionsDir, candidate, { agentId: opts?.agentId }); + } catch { + // Keep handlers alive when persisted metadata is stale/corrupt. + } } return resolveSessionTranscriptPathInDir(sessionId, sessionsDir); } diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index e5b9a72d735..1bcbac5711c 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -56,16 +56,64 @@ describe("session path safety", () => { expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl")); }); - it("rejects absolute sessionFile paths outside known agent sessions dirs", () => { + it("falls back to derived path when sessionFile is outside known agent sessions dirs", () => { const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - expect(() => - resolveSessionFilePath( + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" }, + { sessionsDir }, + ); + expect(resolved).toBe(path.resolve(sessionsDir, "sess-1.jsonl")); + }); + + it("accepts symlink-alias session paths that resolve under the sessions dir", () => { + if (process.platform === "win32") { + return; + } + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-symlink-session-")); + const realRoot = path.join(tmpDir, "real-state"); + const aliasRoot = path.join(tmpDir, "alias-state"); + try { + const sessionsDir = path.join(realRoot, "agents", "main", "sessions"); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.symlinkSync(realRoot, aliasRoot, "dir"); + const viaAlias = path.join(aliasRoot, "agents", "main", "sessions", "sess-1.jsonl"); + fs.writeFileSync(path.join(sessionsDir, "sess-1.jsonl"), ""); + const resolved = resolveSessionFilePath("sess-1", { sessionFile: viaAlias }, { sessionsDir }); + expect(fs.realpathSync(resolved)).toBe( + fs.realpathSync(path.join(sessionsDir, "sess-1.jsonl")), + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("falls back when sessionFile is a symlink that escapes sessions dir", () => { + if (process.platform === "win32") { + return; + } + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-symlink-escape-")); + const sessionsDir = path.join(tmpDir, "agents", "main", "sessions"); + const outsideDir = path.join(tmpDir, "outside"); + try { + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(outsideDir, { recursive: true }); + const outsideFile = path.join(outsideDir, "escaped.jsonl"); + fs.writeFileSync(outsideFile, ""); + const symlinkPath = path.join(sessionsDir, "escaped.jsonl"); + fs.symlinkSync(outsideFile, symlinkPath, "file"); + + const resolved = resolveSessionFilePath( "sess-1", - { sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" }, + { sessionFile: symlinkPath }, { sessionsDir }, - ), - ).toThrow(/within sessions directory/); + ); + expect(fs.realpathSync(path.dirname(resolved))).toBe(fs.realpathSync(sessionsDir)); + expect(path.basename(resolved)).toBe("sess-1.jsonl"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); });