From 216f45af8ec3b354c10abe341ed0436c465292cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 04:11:24 -0700 Subject: [PATCH] fix(memory): preserve Windows session ownership (#115851) * fix(memory): preserve Windows session ownership * test(memory): cover custom and nested Windows transcripts * fix(memory): retain nested and custom Windows transcripts * fix(memory): isolate agent-local Windows transcript stores * style(memory): format Windows ownership regression coverage * fix(memory): validate physical transcript ownership boundaries * fix(memory): bind external session paths to configured agent stores * fix(memory): reject unowned shared transcript store roots * fix(memory): restrict transcript archives to agent-owned roots * refactor(memory): preserve canonical transcript corpus contracts --------- Co-authored-by: Peter Steinberger --- .../src/host/openclaw-runtime-session.ts | 42 ++++- .../memory-host-sdk/src/host/session-files.ts | 10 +- .../session-files.windows-ownership.test.ts | 162 ++++++++++++++++++ .../src/host/session-transcript-corpus.ts | 10 +- 4 files changed, 201 insertions(+), 23 deletions(-) create mode 100644 packages/memory-host-sdk/src/host/session-files.windows-ownership.test.ts diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime-session.ts b/packages/memory-host-sdk/src/host/openclaw-runtime-session.ts index 0629b5715545..01354c19809f 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime-session.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime-session.ts @@ -1,5 +1,6 @@ // Narrow session/runtime facade re-exported for memory transcript helpers. import path from "node:path"; +import { isValidAgentId, normalizeAgentId } from "@openclaw/normalization-core/agent-id"; export { canonicalizeMainSessionAlias, @@ -37,15 +38,46 @@ export { /** Extracts the agent id from a canonical `agents//sessions` directory path. */ export function extractAgentIdFromSessionsDir(sessionsDir: string): string | null { const parts = path.normalize(path.resolve(sessionsDir)).split(path.sep).filter(Boolean); - const sessionsIndex = parts.length - 1; + const sessionsSegment = parts.at(-1); + const agentId = parts.at(-2); + const agentsSegment = parts.at(-3); + const isWindows = process.platform === "win32"; + // Windows preserves path casing while matching canonical segments without it. + // Reject malformed ids before normalization to prevent cross-agent aliasing. if ( - parts[sessionsIndex] !== "sessions" || - sessionsIndex < 2 || - parts[sessionsIndex - 2] !== "agents" + !sessionsSegment || + !agentId || + !agentsSegment || + (isWindows ? sessionsSegment.toLowerCase() : sessionsSegment) !== "sessions" || + (isWindows ? agentsSegment.toLowerCase() : agentsSegment) !== "agents" || + (isWindows && (agentId !== agentId.trim() || !isValidAgentId(agentId))) ) { return null; } - return parts[sessionsIndex - 1] || null; + return isWindows ? normalizeAgentId(agentId) : agentId; +} + +/** Finds the nearest canonical sessions owner without escaping its directory. */ +export function extractAgentIdFromSessionPath(absPath: string): string | null { + let currentDir = path.dirname(path.resolve(absPath)); + while (true) { + const currentSegment = path.basename(currentDir); + const isSessionsDir = + (process.platform === "win32" ? currentSegment.toLowerCase() : currentSegment) === "sessions"; + if (isSessionsDir) { + const agentId = extractAgentIdFromSessionsDir(currentDir); + // Nested transcript folders may also be named `sessions`; only a + // canonical agents//sessions ancestor establishes ownership. + if (agentId) { + return agentId; + } + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } } /** Session-key prefix marking transcripts generated by memory dreaming runs. */ diff --git a/packages/memory-host-sdk/src/host/session-files.ts b/packages/memory-host-sdk/src/host/session-files.ts index 6267074c7c33..2e567bb5ec55 100644 --- a/packages/memory-host-sdk/src/host/session-files.ts +++ b/packages/memory-host-sdk/src/host/session-files.ts @@ -8,6 +8,7 @@ import { createSubsystemLogger, redactSensitiveText } from "./openclaw-runtime-i import { DREAMING_NARRATIVE_RUN_PREFIX, isDreamingNarrativeSessionStoreKey, + extractAgentIdFromSessionPath, extractAgentIdFromSessionsDir, HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, @@ -371,15 +372,6 @@ export async function listSessionFilesForAgent(agentId: string): Promise entry.sessionFile); } -function extractAgentIdFromSessionPath(absPath: string): string | null { - const parts = path.normalize(path.resolve(absPath)).split(path.sep).filter(Boolean); - const sessionsIndex = parts.lastIndexOf("sessions"); - if (sessionsIndex < 2 || parts[sessionsIndex - 2] !== "agents") { - return null; - } - return parts[sessionsIndex - 1] || null; -} - export function sessionPathForFile(absPath: string): string { const agentId = extractAgentIdFromSessionPath(absPath); return path diff --git a/packages/memory-host-sdk/src/host/session-files.windows-ownership.test.ts b/packages/memory-host-sdk/src/host/session-files.windows-ownership.test.ts new file mode 100644 index 000000000000..600da70f3bb6 --- /dev/null +++ b/packages/memory-host-sdk/src/host/session-files.windows-ownership.test.ts @@ -0,0 +1,162 @@ +// Memory transcript owners follow filesystem casing without crossing agents. +import fsSync from "node:fs"; +import path from "node:path"; +import { + clearConfigCache, + clearRuntimeConfigSnapshot, +} from "openclaw/plugin-sdk/runtime-config-snapshot"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { upsertSessionEntry } from "../../../../src/config/sessions/session-accessor.js"; +import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js"; +import { extractAgentIdFromSessionsDir } from "./openclaw-runtime-session.js"; +import { + listSessionTranscriptCorpusEntriesForAgent, + parseCanonicalSessionSyncTargetFromPath, + sessionPathForFile, +} from "./session-files.js"; + +const invalidWindowsAgentIds = ["bad owner", "!!!", " Main", "Main ", "a".repeat(65)]; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +let tmpDir = ""; +let originalStateDir: string | undefined; +let originalConfigPath: string | undefined; + +beforeEach(() => { + tmpDir = tempDirs.make("session-windows-ownership-"); + originalStateDir = process.env.OPENCLAW_STATE_DIR; + originalConfigPath = process.env.OPENCLAW_CONFIG_PATH; + process.env.OPENCLAW_STATE_DIR = tmpDir; + delete process.env.OPENCLAW_CONFIG_PATH; + clearRuntimeConfigSnapshot(); + clearConfigCache(); +}); + +afterEach(() => { + if (originalStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalStateDir; + } + if (originalConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = originalConfigPath; + } + clearRuntimeConfigSnapshot(); + clearConfigCache(); +}); + +describe("memory session directory ownership", () => { + it("preserves the canonical owner for case-variant Windows session directories", () => { + const platform = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + expect(extractAgentIdFromSessionsDir(path.join(tmpDir, "AGENTS", "Main", "SESSIONS"))).toBe( + "main", + ); + } finally { + platform.mockRestore(); + } + }); + + it("keeps case-variant structural segments unowned on case-sensitive platforms", () => { + const platform = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + try { + expect( + extractAgentIdFromSessionsDir(path.join(tmpDir, "AGENTS", "Main", "SESSIONS")), + ).toBeNull(); + } finally { + platform.mockRestore(); + } + }); + + it("preserves case-variant Windows ownership in logical transcript paths", () => { + const platform = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + const sessionFile = path.join(tmpDir, "AGENTS", "Main", "SESSIONS", "active.jsonl"); + expect(sessionPathForFile(sessionFile)).toBe("sessions/main/active.jsonl"); + expect(parseCanonicalSessionSyncTargetFromPath(sessionFile)).toEqual({ + agentId: "main", + sessionId: "active", + }); + } finally { + platform.mockRestore(); + } + }); + + it("preserves case-variant Windows ownership for nested session transcripts", () => { + const platform = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + const sessionFile = path.join( + tmpDir, + "AGENTS", + "OPS", + "SESSIONS", + "archive", + "private.jsonl", + ); + expect(sessionPathForFile(sessionFile)).toBe("sessions/ops/private.jsonl"); + } finally { + platform.mockRestore(); + } + }); + + it("finds the canonical owner past a nested case-variant sessions directory", () => { + const platform = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + const sessionFile = path.join( + tmpDir, + "agents", + "main", + "sessions", + "archive", + "SESSIONS", + "active.jsonl", + ); + expect(sessionPathForFile(sessionFile)).toBe("sessions/main/active.jsonl"); + } finally { + platform.mockRestore(); + } + }); + + it("preserves canonical SQLite session identity on Windows", async () => { + const platform = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + const sessionsDir = path.join(tmpDir, "agents", "main", "sessions"); + const storePath = path.join(sessionsDir, "sessions.json"); + const sessionKey = "agent:main:chat:windows-transcript"; + fsSync.mkdirSync(sessionsDir, { recursive: true }); + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { sessionId: "active", updatedAt: 1 }, + ); + + await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toContainEqual( + expect.objectContaining({ + agentId: "main", + sessionFile: sessionKey, + sessionId: "active", + transcriptSource: "sqlite", + }), + ); + } finally { + platform.mockRestore(); + } + }); + + it.each(invalidWindowsAgentIds)( + "never aliases an invalid Windows session owner into another agent: %s", + (owner) => { + const platform = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + const sessionsDir = path.join(tmpDir, "agents", owner, "sessions"); + const sessionFile = path.join(sessionsDir, "active.jsonl"); + expect(extractAgentIdFromSessionsDir(sessionsDir)).toBeNull(); + expect(sessionPathForFile(sessionFile)).toBe("sessions/active.jsonl"); + expect(parseCanonicalSessionSyncTargetFromPath(sessionFile)).toBeNull(); + } finally { + platform.mockRestore(); + } + }, + ); +}); diff --git a/packages/memory-host-sdk/src/host/session-transcript-corpus.ts b/packages/memory-host-sdk/src/host/session-transcript-corpus.ts index f5f3d70ec3a4..d20002f3069f 100644 --- a/packages/memory-host-sdk/src/host/session-transcript-corpus.ts +++ b/packages/memory-host-sdk/src/host/session-transcript-corpus.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { normalizeAgentId } from "./config-utils.js"; import { isDreamingNarrativeSessionStoreKey, + extractAgentIdFromSessionPath, extractAgentIdFromSessionsDir, canonicalizeMainSessionAlias, getRuntimeConfig, @@ -111,15 +112,6 @@ function rememberArtifactDir(dirs: Map, dir: string): void { dirs.set(normalizeRealComparablePath(dir), dir); } -function extractAgentIdFromSessionPath(absPath: string): string | null { - const parts = path.normalize(path.resolve(absPath)).split(path.sep).filter(Boolean); - const sessionsIndex = parts.lastIndexOf("sessions"); - if (sessionsIndex < 2 || parts[sessionsIndex - 2] !== "agents") { - return null; - } - return parts[sessionsIndex - 1] || null; -} - type ResolvedSessionStoreCorpusSource = { sessionFile: string; sessionId: string;