fix: preserve Unicode sessions during SQLite upgrade (#124951)

* fix(sessions): preserve unicode IDs during migration

Amp-Thread-ID: https://ampcode.com/threads/T-01a00a6a-b64e-74a5-8b15-2d3b966a468d

* fix(sessions): bound topic transcript filenames

Amp-Thread-ID: https://ampcode.com/threads/T-01a00a6a-b64e-74a5-8b15-2d3b966a468d

* fix(sessions): canonicalize Unicode IDs

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-16 21:58:04 -07:00
committed by GitHub
parent 3089decd63
commit 559ee3a209
4 changed files with 88 additions and 3 deletions
+6 -1
View File
@@ -58,12 +58,14 @@ export function resolveSessionFilePathOptions(params: {
return undefined;
}
const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
const SAFE_SESSION_ID_RE = /^[\p{L}\p{N}][\p{L}\p{N}\p{M}._-]{0,127}$/u;
export function validateSessionId(sessionId: string): string {
const trimmed = sessionId.trim();
if (
trimmed !== trimmed.normalize("NFC") ||
!SAFE_SESSION_ID_RE.test(trimmed) ||
Buffer.byteLength(`${trimmed}.jsonl`, "utf8") > 255 ||
isCompactionCheckpointTranscriptFileName(`${trimmed}.jsonl`)
) {
throw new Error(`Invalid session ID: ${sessionId}`);
@@ -276,6 +278,9 @@ export function resolveSessionTranscriptPathInDir(
safeTopicId !== undefined
? `${safeSessionId}-topic-${safeTopicId}.jsonl`
: `${safeSessionId}.jsonl`;
if (Buffer.byteLength(fileName, "utf8") > 255) {
throw new Error(`Invalid session transcript filename: ${fileName}`);
}
return resolvePathWithinSessionsDir(sessionsDir, fileName);
}
+34
View File
@@ -173,13 +173,38 @@ it("drops malformed assistant transcript repair records", () => {
});
describe("session path safety", () => {
it("preserves path-safe Unicode session IDs", () => {
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
for (const sessionId of ["volume-main-会議-000000", "volume-main-हिन्दी-000001"]) {
expect(validateSessionId(sessionId)).toBe(sessionId);
expect(normalizePersistedSessionEntryShape({ sessionId, updatedAt: 42 })).toMatchObject({
sessionId,
updatedAt: 42,
});
expect(resolveSessionTranscriptPathInDir(sessionId, sessionsDir)).toBe(
path.resolve(sessionsDir, `${sessionId}.jsonl`),
);
}
});
it("rejects noncanonical Unicode session IDs", () => {
for (const sessionId of ["session-Å", "session-A\u030A", "session-e\u0301"]) {
expect(() => validateSessionId(sessionId), sessionId).toThrow(/Invalid session ID/);
expect(normalizePersistedSessionEntryShape({ sessionId, updatedAt: 42 })).toBeUndefined();
}
});
it("rejects unsafe session IDs", () => {
const unsafeSessionIds = [
"../etc/passwd",
"a/b",
"a\\b",
"/abs",
"session:legacy",
"session-🙂",
"sess.checkpoint.11111111-1111-4111-8111-111111111111",
`session-${"会".repeat(82)}`,
];
for (const sessionId of unsafeSessionIds) {
expect(() => validateSessionId(sessionId), sessionId).toThrow(/Invalid session ID/);
@@ -193,6 +218,15 @@ describe("session path safety", () => {
expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl"));
});
it("rejects topic-qualified transcript filenames over 255 bytes", () => {
const sessionId = "会".repeat(82);
expect(validateSessionId(sessionId)).toBe(sessionId);
expect(() => resolveSessionTranscriptPathInDir(sessionId, "/tmp/sessions", 1)).toThrow(
/Invalid session transcript filename/,
);
});
it("falls back to derived path when sessionFile is outside known agent sessions dirs", () => {
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
+2 -2
View File
@@ -12,13 +12,13 @@ function isSafeSessionId(value: unknown): value is string {
return false;
}
const trimmed = value.trim();
if (!trimmed || trimmed.length > 255) {
if (!trimmed || trimmed.length > 255 || trimmed !== trimmed.normalize("NFC")) {
return false;
}
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed === "." || trimmed === "..") {
return false;
}
return /^[A-Za-z0-9][A-Za-z0-9._:@-]*$/.test(trimmed);
return /^[\p{L}\p{N}][\p{L}\p{N}\p{M}._:@-]*$/u.test(trimmed);
}
function normalizeTranscriptSessionId(value: string): string | undefined {
+46
View File
@@ -4613,6 +4613,52 @@ describe("state migrations", () => {
await expectMissingPath(legacyStorePath);
});
it("keeps a path-safe Unicode legacy session attached to its transcript", async () => {
const { root, stateDir, env, cfg } = await createLegacyStateFixture();
const sessionId = "volume-main-हिन्दी-会議-000000";
const transcriptName = `${sessionId}.jsonl`;
const legacySessionsDir = path.join(stateDir, "sessions");
const legacyStorePath = path.join(legacySessionsDir, "sessions.json");
const targetSessionsDir = path.join(stateDir, "agents", "worker-1", "sessions");
const targetStorePath = path.join(targetSessionsDir, "sessions.json");
await fs.writeFile(
legacyStorePath,
`${JSON.stringify(
{
unicode: {
sessionFile: path.join(legacySessionsDir, transcriptName),
sessionId,
updatedAt: 100,
},
},
null,
2,
)}\n`,
"utf8",
);
const transcript = `${JSON.stringify({ type: "session", sessionId })}\n`;
await fs.writeFile(path.join(legacySessionsDir, transcriptName), transcript, "utf8");
const detected = await detectLegacyStateMigrations({
cfg,
env,
homedir: () => root,
});
const result = await runLegacyStateMigrations({ detected, now: () => 1234 });
const migratedStore = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record<
string,
{ sessionId?: string }
>;
expect(migratedStore["agent:worker-1:unicode"]?.sessionId).toBe(sessionId);
await expect(fs.readFile(path.join(targetSessionsDir, transcriptName), "utf8")).resolves.toBe(
transcript,
);
await expectMissingPath(legacyStorePath);
expect(result.warnings).toStrictEqual([]);
});
it("defers when an invalid legacy winner would replace an existing target key", async () => {
const { root, stateDir, env, cfg } = await createLegacyStateFixture();