Skip to content

Commit ad5b81e

Browse files
steipetely-wang19
andauthored
fix(memory): trust structured cron archive provenance (#101322)
Prevent user-authored [cron:] text from clearing reset or deleted session memory while preserving structured cron-run opacity. Co-authored-by: ly-wang19 <[email protected]>
1 parent 7900ff4 commit ad5b81e

2 files changed

Lines changed: 53 additions & 31 deletions

File tree

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

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -779,29 +779,59 @@ describe("buildSessionEntry", () => {
779779
expect(checkpointEntry.lineMap).toStrictEqual([]);
780780
});
781781

782-
it("keeps cron-run deleted archives opaque when the live session store entry is gone", async () => {
783-
const archivePath = path.join(tmpDir, "cron-run.jsonl.deleted.2026-02-16T22-27-33.000Z");
784-
const jsonlLines = [
785-
JSON.stringify({
786-
type: "message",
787-
message: {
782+
it.each([
783+
[
784+
"as the first message",
785+
[],
786+
[
787+
"Assistant: The digest job failed because the API token expired.",
788+
"User: Please remember: my preferred vendor is Acme Robotics and budget is 5000 USD.",
789+
"Assistant: Noted. Acme Robotics, budget 5000 USD.",
790+
],
791+
[2, 3, 4],
792+
],
793+
[
794+
"after ordinary messages",
795+
[
796+
{ role: "user", content: "Remember before: project codename is Atlas." },
797+
{ role: "assistant", content: "Saved project codename Atlas." },
798+
],
799+
[
800+
"User: Remember before: project codename is Atlas.",
801+
"Assistant: Saved project codename Atlas.",
802+
"Assistant: The digest job failed because the API token expired.",
803+
"User: Please remember: my preferred vendor is Acme Robotics and budget is 5000 USD.",
804+
"Assistant: Noted. Acme Robotics, budget 5000 USD.",
805+
],
806+
[1, 2, 4, 5, 6],
807+
],
808+
])(
809+
"does not wipe an archive when a user message starts with [cron: %s (#98241)",
810+
async (_position, precedingMessages, expectedContent, expectedLineMap) => {
811+
const archivePath = path.join(tmpDir, "ordinary.jsonl.deleted.2026-02-16T22-27-33.000Z");
812+
const messages = [
813+
...precedingMessages,
814+
{ role: "user", content: "[cron:daily-digest] why did my digest job fail last night?" },
815+
{
816+
role: "assistant",
817+
content: "The digest job failed because the API token expired.",
818+
},
819+
{
788820
role: "user",
789-
content: "[cron:job-1 Codex Sessions Sync] Run internal sync.",
821+
content: "Please remember: my preferred vendor is Acme Robotics and budget is 5000 USD.",
790822
},
791-
}),
792-
JSON.stringify({
793-
type: "message",
794-
message: { role: "assistant", content: "Internal cron output that must stay out." },
795-
}),
796-
];
797-
fsSync.writeFileSync(archivePath, jsonlLines.join("\n"));
823+
{ role: "assistant", content: "Noted. Acme Robotics, budget 5000 USD." },
824+
];
825+
const jsonlLines = messages.map((message) => JSON.stringify({ type: "message", message }));
826+
fsSync.writeFileSync(archivePath, jsonlLines.join("\n"));
798827

799-
const entry = requireSessionEntry(await buildSessionEntry(archivePath));
828+
const entry = requireSessionEntry(await buildSessionEntry(archivePath));
800829

801-
expect(entry.content).toBe("");
802-
expect(entry.lineMap).toStrictEqual([]);
803-
expect(entry.generatedByCronRun).toBe(true);
804-
});
830+
expect(entry.generatedByCronRun).toBeFalsy();
831+
expect(entry.content).toBe(expectedContent.join("\n"));
832+
expect(entry.lineMap).toStrictEqual(expectedLineMap);
833+
},
834+
);
805835

806836
it("keeps cron-run reset archives opaque when session metadata preserves the cron key", async () => {
807837
const archivePath = path.join(tmpDir, "cron-run.jsonl.reset.2026-02-16T22-26-33.000Z");

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

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,7 @@ export async function buildSessionEntry(
762762
false;
763763
let generatedByCronRun =
764764
opts.generatedByCronRun ?? sessionStoreClassification?.generatedByCronRun ?? false;
765-
const allowArchiveContentCronClassification =
765+
const allowArchiveRecordCronClassification =
766766
isUsageCountedSessionArchiveTranscriptPath(absPath);
767767
for (let jsonlIdx = 0, lineStart = 0; lineStart <= raw.length; jsonlIdx++) {
768768
await yieldSessionEntryParseIfNeeded(jsonlIdx, parseYieldEveryLines);
@@ -784,7 +784,7 @@ export async function buildSessionEntry(
784784
}
785785
if (
786786
!generatedByCronRun &&
787-
allowArchiveContentCronClassification &&
787+
allowArchiveRecordCronClassification &&
788788
isCronRunGeneratedRecord(record)
789789
) {
790790
generatedByCronRun = true;
@@ -815,16 +815,8 @@ export async function buildSessionEntry(
815815
if (rawText === null) {
816816
continue;
817817
}
818-
if (
819-
!generatedByCronRun &&
820-
allowArchiveContentCronClassification &&
821-
isGeneratedCronPromptMessage(normalizeSessionText(rawText), message.role)
822-
) {
823-
generatedByCronRun = true;
824-
collected.length = 0;
825-
lineMap.length = 0;
826-
messageTimestampsMs.length = 0;
827-
}
818+
// User text is not trusted archive-wide provenance. Per-message sanitization
819+
// drops cron prompts without clearing unrelated content from the archive.
828820
const text = sanitizeSessionText(rawText, message.role);
829821
if (!text) {
830822
// Assistant-side machinery (silent replies, system wrappers) is already

0 commit comments

Comments
 (0)