diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 5e66d36237d..26696d60ac7 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -561,6 +561,43 @@ describe("sessions", () => { }); }); + it("falls back when structural cross-root path traverses after sessions", () => { + withStateDir(path.resolve("/different/state"), () => { + const originalBase = path.resolve("/original/state"); + const unsafe = path.join(originalBase, "agents", "bot2", "sessions", "..", "..", "etc"); + const sessionFile = resolveSessionFilePath( + "sess-1", + { sessionFile: path.join(unsafe, "passwd") }, + { agentId: "bot1" }, + ); + expect(sessionFile).toBe( + path.join(path.resolve("/different/state"), "agents", "bot1", "sessions", "sess-1.jsonl"), + ); + }); + }); + + it("falls back when structural cross-root path nests under sessions", () => { + withStateDir(path.resolve("/different/state"), () => { + const originalBase = path.resolve("/original/state"); + const nested = path.join( + originalBase, + "agents", + "bot2", + "sessions", + "nested", + "sess-1.jsonl", + ); + const sessionFile = resolveSessionFilePath( + "sess-1", + { sessionFile: nested }, + { agentId: "bot1" }, + ); + expect(sessionFile).toBe( + path.join(path.resolve("/different/state"), "agents", "bot1", "sessions", "sess-1.jsonl"), + ); + }); + }); + it("falls back to derived transcript path when sessionFile is outside agent sessions directories", () => { withStateDir(path.resolve("/home/user/.openclaw"), () => { const sessionFile = resolveSessionFilePath( diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 53e6c9c19f0..0d3c0d6a2ab 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -115,6 +115,39 @@ function extractAgentIdFromAbsoluteSessionPath(candidateAbsPath: string): string return agentId || undefined; } +function resolveStructuralSessionFallbackPath( + candidateAbsPath: string, + expectedAgentId: string, +): string | undefined { + const normalized = path.normalize(path.resolve(candidateAbsPath)); + const parts = normalized.split(path.sep).filter(Boolean); + const sessionsIndex = parts.lastIndexOf("sessions"); + if (sessionsIndex < 2 || parts[sessionsIndex - 2] !== "agents") { + return undefined; + } + const agentIdPart = parts[sessionsIndex - 1]; + if (!agentIdPart) { + return undefined; + } + const normalizedAgentId = normalizeAgentId(agentIdPart); + if (normalizedAgentId !== agentIdPart.toLowerCase()) { + return undefined; + } + if (normalizedAgentId !== normalizeAgentId(expectedAgentId)) { + return undefined; + } + const relativeSegments = parts.slice(sessionsIndex + 1); + // Session transcripts are stored as direct files in "sessions/". + if (relativeSegments.length !== 1) { + return undefined; + } + const fileName = relativeSegments[0]; + if (!fileName || fileName === "." || fileName === "..") { + return undefined; + } + return normalized; +} + function safeRealpathSync(filePath: string): string | undefined { try { return fs.realpathSync(filePath); @@ -170,11 +203,15 @@ function resolvePathWithinSessionsDir( if (resolvedFromPath) { return resolvedFromPath; } - // The path structurally matches .../agents//sessions/... - // 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(realTrimmed); + // Cross-root compatibility for older absolute paths: + // keep only canonical .../agents//sessions/ shapes. + const structuralFallback = resolveStructuralSessionFallbackPath( + realTrimmed, + extractedAgentId, + ); + if (structuralFallback) { + return structuralFallback; + } } } if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) {