Skip to content

Commit 4881323

Browse files
committed
fix(memory): keep archive transcript visibility safe
1 parent 0c9de1e commit 4881323

6 files changed

Lines changed: 292 additions & 30 deletions

File tree

extensions/memory-core/src/session-search-visibility.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const crossAgentStore = {
1111
sessionFile: "/tmp/sessions/w1.jsonl",
1212
},
1313
};
14+
let combinedSessionStore: typeof crossAgentStore | Record<string, never> = crossAgentStore;
1415

1516
vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => {
1617
const actual =
@@ -19,14 +20,15 @@ vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) =>
1920
...actual,
2021
loadCombinedSessionStoreForGateway: vi.fn(() => ({
2122
storePath: "(test)",
22-
store: crossAgentStore,
23+
store: combinedSessionStore,
2324
})),
2425
};
2526
});
2627

2728
describe("filterMemorySearchHitsBySessionVisibility", () => {
2829
afterEach(() => {
2930
vi.mocked(sessionTranscriptHit.loadCombinedSessionStoreForGateway).mockClear();
31+
combinedSessionStore = crossAgentStore;
3032
});
3133

3234
it("drops sessions-sourced hits when requester key is missing (fail closed)", async () => {
@@ -148,4 +150,57 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
148150
});
149151
expect(filtered).toEqual([]);
150152
});
153+
154+
it("keeps same-agent deleted archive hits using owner metadata when the live store entry is gone", async () => {
155+
combinedSessionStore = {};
156+
const hit: MemorySearchResult = {
157+
path: "sessions/main/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z",
158+
source: "sessions",
159+
score: 1,
160+
snippet: "x",
161+
startLine: 1,
162+
endLine: 2,
163+
};
164+
const cfg = asOpenClawConfig({
165+
tools: {
166+
sessions: { visibility: "agent" },
167+
},
168+
});
169+
170+
const filtered = await filterMemorySearchHitsBySessionVisibility({
171+
cfg,
172+
requesterSessionKey: "agent:main:main",
173+
sandboxed: false,
174+
hits: [hit],
175+
});
176+
177+
expect(filtered).toEqual([hit]);
178+
});
179+
180+
it("still denies cross-agent deleted archive hits resolved from owner metadata when a2a is disabled", async () => {
181+
combinedSessionStore = {};
182+
const hit: MemorySearchResult = {
183+
path: "sessions/peer/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z",
184+
source: "sessions",
185+
score: 1,
186+
snippet: "x",
187+
startLine: 1,
188+
endLine: 2,
189+
};
190+
const cfg = asOpenClawConfig({
191+
tools: {
192+
sessions: { visibility: "all" },
193+
agentToAgent: { enabled: false },
194+
},
195+
});
196+
197+
const filtered = await filterMemorySearchHitsBySessionVisibility({
198+
cfg,
199+
requesterSessionKey: "agent:main:main",
200+
sandboxed: false,
201+
hits: [hit],
202+
});
203+
204+
expect(filtered).toEqual([]);
205+
});
151206
});

extensions/memory-core/src/session-search-visibility.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
22
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
33
import {
4-
extractTranscriptStemFromSessionsMemoryHit,
4+
extractTranscriptIdentityFromSessionsMemoryHit,
55
loadCombinedSessionStoreForGateway,
66
resolveTranscriptStemToSessionKeys,
77
} from "openclaw/plugin-sdk/session-transcript-hit";
@@ -42,13 +42,16 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
4242
if (!params.requesterSessionKey || !guard) {
4343
continue;
4444
}
45-
const stem = extractTranscriptStemFromSessionsMemoryHit(hit.path);
46-
if (!stem) {
45+
const identity = extractTranscriptIdentityFromSessionsMemoryHit(hit.path);
46+
if (!identity) {
4747
continue;
4848
}
4949
const keys = resolveTranscriptStemToSessionKeys({
5050
store: combinedSessionStore,
51-
stem,
51+
stem: identity.stem,
52+
...(identity.archived && identity.ownerAgentId
53+
? { archivedOwnerAgentId: identity.ownerAgentId }
54+
: {}),
5255
});
5356
if (keys.length === 0) {
5457
continue;

packages/memory-host-sdk/src/host/session-files.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import fsSync from "node:fs";
22
import os from "node:os";
33
import path from "node:path";
44
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
5-
import { buildSessionEntry, listSessionFilesForAgent } from "./session-files.js";
5+
import {
6+
buildSessionEntry,
7+
listSessionFilesForAgent,
8+
sessionPathForFile,
9+
} from "./session-files.js";
610

711
let fixtureRoot: string;
812
let tmpDir: string;
@@ -61,6 +65,28 @@ describe("listSessionFilesForAgent", () => {
6165
});
6266
});
6367

68+
describe("sessionPathForFile", () => {
69+
it("includes the owning agent id when the transcript lives under an agent sessions dir", () => {
70+
const absPath = path.join(
71+
tmpDir,
72+
"agents",
73+
"main",
74+
"sessions",
75+
"deleted-session.jsonl.deleted.2026-02-16T22-27-33.000Z",
76+
);
77+
78+
expect(sessionPathForFile(absPath)).toBe(
79+
"sessions/main/deleted-session.jsonl.deleted.2026-02-16T22-27-33.000Z",
80+
);
81+
});
82+
83+
it("keeps the legacy basename-only path when the agent owner cannot be derived", () => {
84+
expect(sessionPathForFile(path.join(tmpDir, "loose-session.jsonl"))).toBe(
85+
"sessions/loose-session.jsonl",
86+
);
87+
});
88+
});
89+
6490
describe("buildSessionEntry", () => {
6591
it("returns lineMap tracking original JSONL line numbers", async () => {
6692
// Simulate a real session JSONL file with metadata records interspersed
@@ -155,6 +181,53 @@ describe("buildSessionEntry", () => {
155181
expect(checkpointEntry?.lineMap).toEqual([]);
156182
});
157183

184+
it("keeps cron-run deleted archives opaque when the live session store entry is gone", async () => {
185+
const archivePath = path.join(tmpDir, "cron-run.jsonl.deleted.2026-02-16T22-27-33.000Z");
186+
const jsonlLines = [
187+
JSON.stringify({
188+
type: "message",
189+
message: {
190+
role: "user",
191+
content: "[cron:job-1 Codex Sessions Sync] Run internal sync.",
192+
},
193+
}),
194+
JSON.stringify({
195+
type: "message",
196+
message: { role: "assistant", content: "Internal cron output that must stay out." },
197+
}),
198+
];
199+
fsSync.writeFileSync(archivePath, jsonlLines.join("\n"));
200+
201+
const entry = await buildSessionEntry(archivePath);
202+
203+
expect(entry).not.toBeNull();
204+
expect(entry?.content).toBe("");
205+
expect(entry?.lineMap).toEqual([]);
206+
expect(entry?.generatedByCronRun).toBe(true);
207+
});
208+
209+
it("keeps cron-run reset archives opaque when session metadata preserves the cron key", async () => {
210+
const archivePath = path.join(tmpDir, "cron-run.jsonl.reset.2026-02-16T22-26-33.000Z");
211+
const jsonlLines = [
212+
JSON.stringify({
213+
type: "session-meta",
214+
data: { sessionKey: "agent:main:cron:job-1:run:run-1" },
215+
}),
216+
JSON.stringify({
217+
type: "message",
218+
message: { role: "assistant", content: "Internal cron output that must stay out." },
219+
}),
220+
];
221+
fsSync.writeFileSync(archivePath, jsonlLines.join("\n"));
222+
223+
const entry = await buildSessionEntry(archivePath);
224+
225+
expect(entry).not.toBeNull();
226+
expect(entry?.content).toBe("");
227+
expect(entry?.lineMap).toEqual([]);
228+
expect(entry?.generatedByCronRun).toBe(true);
229+
});
230+
158231
it("skips blank lines and invalid JSON without breaking lineMap", async () => {
159232
const jsonlLines = [
160233
"",

packages/memory-host-sdk/src/host/session-files.ts

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
isSessionArchiveArtifactName,
1515
isSilentReplyPayloadText,
1616
isUsageCountedSessionTranscriptFileName,
17+
parseUsageCountedSessionIdFromFileName,
1718
resolveSessionTranscriptsDirForAgent,
1819
stripInboundMetadata,
1920
stripInternalRuntimeContext,
@@ -82,6 +83,15 @@ function shouldSkipTranscriptFileForDreaming(absPath: string): boolean {
8283
return false;
8384
}
8485

86+
function isUsageCountedSessionArchiveTranscriptPath(absPath: string): boolean {
87+
const fileName = path.basename(absPath);
88+
return (
89+
isUsageCountedSessionTranscriptFileName(fileName) &&
90+
isSessionArchiveArtifactName(fileName) &&
91+
parseUsageCountedSessionIdFromFileName(fileName) !== null
92+
);
93+
}
94+
8595
function isDreamingNarrativeBootstrapRecord(record: unknown): boolean {
8696
if (!record || typeof record !== "object" || Array.isArray(record)) {
8797
return false;
@@ -150,6 +160,30 @@ function isDreamingNarrativeSessionStoreKey(sessionKey: string): boolean {
150160
return sessionSegment.startsWith(DREAMING_NARRATIVE_RUN_PREFIX);
151161
}
152162

163+
function hasCronRunSessionKey(value: unknown): boolean {
164+
return typeof value === "string" && isCronRunSessionKey(value);
165+
}
166+
167+
function isCronRunGeneratedRecord(record: unknown): boolean {
168+
if (!record || typeof record !== "object" || Array.isArray(record)) {
169+
return false;
170+
}
171+
const candidate = record as {
172+
sessionKey?: unknown;
173+
data?: unknown;
174+
};
175+
if (hasCronRunSessionKey(candidate.sessionKey)) {
176+
return true;
177+
}
178+
if (!candidate.data || typeof candidate.data !== "object" || Array.isArray(candidate.data)) {
179+
return false;
180+
}
181+
const nested = candidate.data as {
182+
sessionKey?: unknown;
183+
};
184+
return hasCronRunSessionKey(nested.sessionKey);
185+
}
186+
153187
function normalizeComparablePath(pathname: string): string {
154188
const resolved = path.resolve(pathname);
155189
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
@@ -242,11 +276,20 @@ function classifySessionTranscriptFromSessionStore(absPath: string): {
242276
} {
243277
const sessionsDir = path.dirname(absPath);
244278
const normalizedAbsPath = normalizeComparablePath(absPath);
279+
const primarySessionId = parseUsageCountedSessionIdFromFileName(path.basename(absPath));
280+
const normalizedPrimaryPath =
281+
primarySessionId && isSessionArchiveArtifactName(path.basename(absPath))
282+
? normalizeComparablePath(path.join(sessionsDir, `${primarySessionId}.jsonl`))
283+
: null;
245284
const classification = loadSessionTranscriptClassificationForSessionsDir(sessionsDir);
285+
const hasClassifiedPath = (paths: ReadonlySet<string>) =>
286+
paths.has(normalizedAbsPath) ||
287+
(normalizedPrimaryPath !== null && paths.has(normalizedPrimaryPath));
246288
return {
247-
generatedByDreamingNarrative:
248-
classification.dreamingNarrativeTranscriptPaths.has(normalizedAbsPath),
249-
generatedByCronRun: classification.cronRunTranscriptPaths.has(normalizedAbsPath),
289+
generatedByDreamingNarrative: hasClassifiedPath(
290+
classification.dreamingNarrativeTranscriptPaths,
291+
),
292+
generatedByCronRun: hasClassifiedPath(classification.cronRunTranscriptPaths),
250293
};
251294
}
252295

@@ -264,8 +307,20 @@ export async function listSessionFilesForAgent(agentId: string): Promise<string[
264307
}
265308
}
266309

310+
function extractAgentIdFromSessionPath(absPath: string): string | null {
311+
const parts = path.normalize(path.resolve(absPath)).split(path.sep).filter(Boolean);
312+
const sessionsIndex = parts.lastIndexOf("sessions");
313+
if (sessionsIndex < 2 || parts[sessionsIndex - 2] !== "agents") {
314+
return null;
315+
}
316+
return parts[sessionsIndex - 1] || null;
317+
}
318+
267319
export function sessionPathForFile(absPath: string): string {
268-
return path.join("sessions", path.basename(absPath)).replace(/\\/g, "/");
320+
const agentId = extractAgentIdFromSessionPath(absPath);
321+
return path
322+
.join("sessions", ...(agentId ? [agentId] : []), path.basename(absPath))
323+
.replace(/\\/g, "/");
269324
}
270325

271326
async function logSessionFileReadFailure(absPath: string, err: unknown): Promise<void> {
@@ -495,8 +550,10 @@ export async function buildSessionEntry(
495550
opts.generatedByDreamingNarrative ??
496551
sessionStoreClassification?.generatedByDreamingNarrative ??
497552
false;
498-
const generatedByCronRun =
553+
let generatedByCronRun =
499554
opts.generatedByCronRun ?? sessionStoreClassification?.generatedByCronRun ?? false;
555+
const allowArchiveContentCronClassification =
556+
isUsageCountedSessionArchiveTranscriptPath(absPath);
500557
for (let jsonlIdx = 0; jsonlIdx < lines.length; jsonlIdx++) {
501558
const line = lines[jsonlIdx];
502559
if (!line.trim()) {
@@ -511,6 +568,16 @@ export async function buildSessionEntry(
511568
if (!generatedByDreamingNarrative && isDreamingNarrativeGeneratedRecord(record)) {
512569
generatedByDreamingNarrative = true;
513570
}
571+
if (
572+
!generatedByCronRun &&
573+
allowArchiveContentCronClassification &&
574+
isCronRunGeneratedRecord(record)
575+
) {
576+
generatedByCronRun = true;
577+
collected.length = 0;
578+
lineMap.length = 0;
579+
messageTimestampsMs.length = 0;
580+
}
514581
if (
515582
!record ||
516583
typeof record !== "object" ||
@@ -534,6 +601,16 @@ export async function buildSessionEntry(
534601
if (rawText === null) {
535602
continue;
536603
}
604+
if (
605+
!generatedByCronRun &&
606+
allowArchiveContentCronClassification &&
607+
isGeneratedCronPromptMessage(normalizeSessionText(rawText), message.role)
608+
) {
609+
generatedByCronRun = true;
610+
collected.length = 0;
611+
lineMap.length = 0;
612+
messageTimestampsMs.length = 0;
613+
}
537614
const text = sanitizeSessionText(rawText, message.role);
538615
if (!text) {
539616
// Assistant-side machinery (silent replies, system wrappers) is already

0 commit comments

Comments
 (0)