From 559ee3a2097828e25ef73eaf527a556f07bb327f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 21:58:04 -0700 Subject: [PATCH] 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 --- src/config/sessions/paths.ts | 7 +++- src/config/sessions/sessions.test.ts | 34 ++++++++++++++++++ src/config/sessions/store-entry-shape.ts | 4 +-- src/infra/state-migrations.test.ts | 46 ++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 713ce99bcc15..7b63bc00bfd7 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -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); } diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 64291fdd9e08..f6798374f82d 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -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"; diff --git a/src/config/sessions/store-entry-shape.ts b/src/config/sessions/store-entry-shape.ts index 71462f50c18b..1fb996b05231 100644 --- a/src/config/sessions/store-entry-shape.ts +++ b/src/config/sessions/store-entry-shape.ts @@ -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 { diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index b89e319ab81c..5d136d1112dc 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -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();