mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
bc3a4c6757
commit
216f45af8e
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user