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 <steipete@gmail.com>
This commit is contained in:
Peter Steinberger
2026-07-29 04:11:24 -07:00
committed by GitHub
parent bc3a4c6757
commit 216f45af8e
4 changed files with 201 additions and 23 deletions
@@ -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/<id>/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/<id>/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. */
@@ -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<string[
.map((entry) => 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
@@ -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();
}
},
);
});
@@ -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<string, string>, 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;