mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
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:
committed by
GitHub
parent
3089decd63
commit
559ee3a209
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user