import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import { handleMemoryApi } from '../api/memory.js' import { sanitizePath } from '../../utils/path.js' let tmpDir: string let originalConfigDir: string | undefined let originalHome: string | undefined let originalUserProfile: string | undefined beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-memory-api-')) originalConfigDir = process.env.CLAUDE_CONFIG_DIR originalHome = process.env.HOME originalUserProfile = process.env.USERPROFILE process.env.CLAUDE_CONFIG_DIR = tmpDir process.env.HOME = tmpDir process.env.USERPROFILE = tmpDir }) afterEach(async () => { if (originalConfigDir !== undefined) { process.env.CLAUDE_CONFIG_DIR = originalConfigDir } else { delete process.env.CLAUDE_CONFIG_DIR } if (originalHome !== undefined) { process.env.HOME = originalHome } else { delete process.env.HOME } if (originalUserProfile !== undefined) { process.env.USERPROFILE = originalUserProfile } else { delete process.env.USERPROFILE } await fs.rm(tmpDir, { recursive: true, force: true }) }) describe('memory API', () => { it('lists current project memory files with frontmatter metadata', async () => { const cwd = path.join(tmpDir, 'workspace', 'app') const projectId = sanitizePath(cwd) const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory') await fs.mkdir(path.join(memoryDir, 'notes'), { recursive: true }) await fs.writeFile( path.join(memoryDir, 'MEMORY.md'), [ '---', 'type: project', 'description: Stable project conventions.', '---', '', '# Project Memory', ].join('\n'), ) await fs.writeFile(path.join(memoryDir, 'notes', 'manual.md'), '# Manual') const projectsRes = await request('GET', `/api/memory/projects?cwd=${encodeURIComponent(cwd)}`) expect(projectsRes.status).toBe(200) const projectsBody = await projectsRes.json() as { projects: Array<{ id: string; isCurrent: boolean; fileCount: number }> } expect(projectsBody.projects[0]).toMatchObject({ id: projectId, isCurrent: true, fileCount: 2, }) const filesRes = await request('GET', `/api/memory/files?projectId=${encodeURIComponent(projectId)}`) expect(filesRes.status).toBe(200) const filesBody = await filesRes.json() as { files: Array<{ path: string; type?: string; description?: string; isIndex: boolean }> } expect(filesBody.files[0]).toMatchObject({ path: 'MEMORY.md', type: 'project', description: 'Stable project conventions.', isIndex: true, }) expect(filesBody.files.some((file) => file.path === 'notes/manual.md')).toBe(true) }) it('uses session metadata for project labels when sanitized paths contain non-ascii characters', async () => { const cwd = path.join(tmpDir, '中文 项目', 'GLM', '5V', 'turbo') const projectId = sanitizePath(cwd) const projectDir = path.join(tmpDir, 'projects', projectId) const memoryDir = path.join(projectDir, 'memory') await fs.mkdir(memoryDir, { recursive: true }) await fs.writeFile(path.join(memoryDir, 'MEMORY.md'), '# Project Memory') await fs.writeFile( path.join(projectDir, 'session.jsonl'), JSON.stringify({ type: 'user', cwd, message: { role: 'user', content: 'hello' }, }) + '\n', ) const projectsRes = await request('GET', '/api/memory/projects') expect(projectsRes.status).toBe(200) const projectsBody = await projectsRes.json() as { projects: Array<{ id: string; label: string }> } const project = projectsBody.projects.find((item) => item.id === projectId) expect(project).toMatchObject({ label: cwd }) }) it('recovers project labels from existing directories when no session metadata exists', async () => { const cwd = path.join(tmpDir, '个人自媒体', '314', 'opus4', 'PicTacticAgent') const projectId = sanitizePath(cwd) const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory') await fs.mkdir(cwd, { recursive: true }) await fs.mkdir(memoryDir, { recursive: true }) await fs.writeFile(path.join(memoryDir, 'MEMORY.md'), '# Project Memory') const projectsRes = await request('GET', '/api/memory/projects') expect(projectsRes.status).toBe(200) const projectsBody = await projectsRes.json() as { projects: Array<{ id: string; label: string }> } const project = projectsBody.projects.find((item) => item.id === projectId) expect(project).toMatchObject({ label: cwd }) }) it('reads and writes only markdown files inside the project memory directory', async () => { const projectId = sanitizePath(path.join(tmpDir, 'workspace', 'app')) const writeRes = await request('PUT', '/api/memory/file', { projectId, path: 'notes/project.md', content: '# Edited Memory\n', }) expect(writeRes.status).toBe(200) const filePath = path.join(tmpDir, 'projects', projectId, 'memory', 'notes', 'project.md') expect(await fs.readFile(filePath, 'utf-8')).toBe('# Edited Memory\n') const readRes = await request('GET', `/api/memory/file?projectId=${encodeURIComponent(projectId)}&path=notes%2Fproject.md`) expect(readRes.status).toBe(200) const body = await readRes.json() as { file: { path: string; content: string } } expect(body.file).toMatchObject({ path: 'notes/project.md', content: '# Edited Memory\n', }) }) it('rejects traversal and symlink escapes', async () => { const projectId = sanitizePath(path.join(tmpDir, 'workspace', 'app')) const memoryDir = path.join(tmpDir, 'projects', projectId, 'memory') const outsideDir = path.join(tmpDir, 'outside') await fs.mkdir(memoryDir, { recursive: true }) await fs.mkdir(outsideDir, { recursive: true }) await fs.symlink(outsideDir, path.join(memoryDir, 'linked'), 'dir') const traversalRes = await request('PUT', '/api/memory/file', { projectId, path: '../outside.md', content: 'escape', }) expect(traversalRes.status).toBe(400) const symlinkRes = await request('PUT', '/api/memory/file', { projectId, path: 'linked/outside.md', content: 'escape', }) expect(symlinkRes.status).toBe(400) await expect(fs.readFile(path.join(outsideDir, 'outside.md'), 'utf-8')).rejects.toThrow() }) }) function request(method: string, pathname: string, body?: Record): Promise { const url = new URL(pathname, 'http://localhost:3456') const init: RequestInit = { method } if (body) { init.headers = { 'Content-Type': 'application/json' } init.body = JSON.stringify(body) } return handleMemoryApi( new Request(url.toString(), init), url, url.pathname.split('/').filter(Boolean), ) }