diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d927b6..d6cd46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed +- Bound loaded user profiles and server memory to their configured context limits before reusing persisted content. - Prevent mixed reaction replies from narrating the bot's internal choice to react while preserving natural reaction-plus-text responses. - Run Discord-initiated Claude login in a pseudo-terminal so the CLI accepts submitted OAuth codes. - Isolate saved history and summaries by Discord channel ID in dedicated storage namespaces so same-named channels do not share automatic context. diff --git a/src/storage/profiles.ts b/src/storage/profiles.ts index ce4cf34..f8040fb 100644 --- a/src/storage/profiles.ts +++ b/src/storage/profiles.ts @@ -33,16 +33,39 @@ function serializeUpdate( }); } +function readBounded(filePath: string, maxChars: number): string { + if (!fs.existsSync(filePath)) return ""; + const text = fs.readFileSync(filePath, "utf-8"); + let end = Math.min(text.length, maxChars); + + if (end > 0 && end < text.length) { + const precedingCodeUnit = text.charCodeAt(end - 1); + const followingCodeUnit = text.charCodeAt(end); + if ( + precedingCodeUnit >= 0xd800 && + precedingCodeUnit <= 0xdbff && + followingCodeUnit >= 0xdc00 && + followingCodeUnit <= 0xdfff + ) { + end--; + } + } + + return text.slice(0, end); +} + export function getUserProfile(userId: string): string { - const filePath = path.join(PROFILES_DIR, `${userId}.txt`); - if (fs.existsSync(filePath)) return fs.readFileSync(filePath, "utf-8"); - return ""; + return readBounded( + path.join(PROFILES_DIR, `${userId}.txt`), + PROFILE_MAX_CHARS, + ); } export function getServerMemory(guildId: string): string { - const filePath = path.join(PROFILES_DIR, `server_${guildId}.txt`); - if (fs.existsSync(filePath)) return fs.readFileSync(filePath, "utf-8"); - return ""; + return readBounded( + path.join(PROFILES_DIR, `server_${guildId}.txt`), + SERVER_MEMORY_MAX_CHARS, + ); } export async function backgroundProfileUpdate( diff --git a/tests/profileStorageBounds.test.mjs b/tests/profileStorageBounds.test.mjs new file mode 100644 index 0000000..f684bb6 --- /dev/null +++ b/tests/profileStorageBounds.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +test("caps oversized persisted profiles and server memory when loaded", () => { + const messagesDir = fs.mkdtempSync( + path.join(os.tmpdir(), "claudify-profile-bounds-"), + ); + const profilesDir = path.join(messagesDir, "profiles"); + fs.mkdirSync(profilesDir, { recursive: true }); + fs.writeFileSync( + path.join(profilesDir, "user-1.txt"), + "P".repeat(2500), + "utf8", + ); + fs.writeFileSync( + path.join(profilesDir, "server_guild-1.txt"), + "M".repeat(12000), + "utf8", + ); + const unicodeProfilePrefix = "U".repeat(1999); + const unicodeMemoryPrefix = "N".repeat(9999); + fs.writeFileSync( + path.join(profilesDir, "unicode-user.txt"), + `${unicodeProfilePrefix}😀tail`, + "utf8", + ); + fs.writeFileSync( + path.join(profilesDir, "server_unicode-guild.txt"), + `${unicodeMemoryPrefix}😀tail`, + "utf8", + ); + + const profilesUrl = new URL("../build/storage/profiles.js", import.meta.url).href; + const script = [ + `const profiles = await import(${JSON.stringify(profilesUrl)});`, + "process.stdout.write(JSON.stringify({ user: profiles.getUserProfile('user-1'), server: profiles.getServerMemory('guild-1'), unicodeUser: profiles.getUserProfile('unicode-user'), unicodeServer: profiles.getServerMemory('unicode-guild') }));", + ].join("\n"); + + try { + const result = spawnSync( + process.execPath, + ["--input-type=module", "--eval", script], + { + encoding: "utf8", + env: { ...process.env, MESSAGES_DIR: messagesDir }, + }, + ); + assert.equal(result.status, 0, result.stderr); + const loaded = JSON.parse(result.stdout); + assert.equal(loaded.user.length, 2000); + assert.equal(loaded.server.length, 10000); + assert.equal(loaded.user, "P".repeat(2000)); + assert.equal(loaded.server, "M".repeat(10000)); + assert.equal(loaded.unicodeUser, unicodeProfilePrefix); + assert.equal(loaded.unicodeServer, unicodeMemoryPrefix); + } finally { + fs.rmSync(messagesDir, { recursive: true, force: true }); + } +});