Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions server/__tests__/claude-dir-resolve.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';

const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalDatabasePath = process.env.DATABASE_PATH;

let tempRoot = null;

async function loadTestModules() {
vi.resetModules();
const projects = await import('../projects.js');
const database = await import('../database/db.js');
await database.initializeDatabase();
return { projects, database };
}

// Mirrors how the Claude CLI currently derives ~/.claude/projects dir names.
// The resolver under test must NOT depend on this rule — it discovers
// directories through the cwd recorded in their jsonl files.
function cliEncode(projectPath) {
return projectPath.replace(/[^a-zA-Z0-9]/g, '-');
}

async function writeClaudeSessionFile({ dirName, sessionId, cwd, userMessage = 'Hello' }) {
const projectDir = path.join(tempRoot, '.claude', 'projects', dirName);
await mkdir(projectDir, { recursive: true });

const userUuid = `user-${sessionId}`;
const lines = [
{
sessionId,
type: 'user',
uuid: userUuid,
parentUuid: null,
cwd,
timestamp: '2026-06-09T10:00:00.000Z',
message: { role: 'user', content: userMessage },
},
{
sessionId,
type: 'assistant',
uuid: `assistant-${sessionId}`,
parentUuid: userUuid,
cwd,
timestamp: '2026-06-09T10:00:05.000Z',
message: { role: 'assistant', content: [{ type: 'text', text: 'Hi there' }] },
},
].map((entry) => JSON.stringify(entry)).join('\n');

const sessionFile = path.join(projectDir, `${sessionId}.jsonl`);
await writeFile(sessionFile, `${lines}\n`, 'utf8');
return sessionFile;
}

async function writeProjectConfig(config) {
const claudeDir = path.join(tempRoot, '.claude');
await mkdir(claudeDir, { recursive: true });
await writeFile(path.join(claudeDir, 'project-config.json'), JSON.stringify(config, null, 2), 'utf8');
}

describe('Claude CLI session directory resolution', () => {
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'dr-claw-dir-resolve-'));
process.env.HOME = tempRoot;
process.env.USERPROFILE = tempRoot;
process.env.DATABASE_PATH = path.join(tempRoot, 'db', 'auth.db');
});

afterEach(async () => {
vi.resetModules();

if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;

if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;

if (originalDatabasePath === undefined) delete process.env.DATABASE_PATH;
else process.env.DATABASE_PATH = originalDatabasePath;

if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
}
});

it('lists sessions for a project whose path contains non-ASCII characters', async () => {
const projectPath = path.join(tempRoot, '项目', '子目录', 'demo');
const cliDirName = cliEncode(projectPath);
// dr-claw's own encoding keeps the Chinese characters, so the project name
// never matches the CLI directory name.
const sessionId = 'a1b2c3d4-0000-4000-8000-aaaaaaaaaaaa';

await writeClaudeSessionFile({ dirName: cliDirName, sessionId, cwd: projectPath });

const { projects } = await loadTestModules();
const projectName = projects.encodeProjectPath(projectPath);
expect(projectName).not.toBe(cliDirName);

await writeProjectConfig({
[projectName]: { manuallyAdded: true, originalPath: projectPath },
});

const result = await projects.getSessions(projectName, 5, 0);
expect(result.total).toBe(1);
expect(result.sessions[0].id).toBe(sessionId);
});

it('reads session messages across resolved directories', async () => {
const projectPath = path.join(tempRoot, '项目', 'demo');
const sessionId = 'a1b2c3d4-0000-4000-8000-bbbbbbbbbbbb';

await writeClaudeSessionFile({
dirName: cliEncode(projectPath),
sessionId,
cwd: projectPath,
userMessage: 'Find my messages',
});

const { projects } = await loadTestModules();
const projectName = projects.encodeProjectPath(projectPath);
await writeProjectConfig({
[projectName]: { manuallyAdded: true, originalPath: projectPath },
});

const messages = await projects.getSessionMessages(projectName, sessionId);
expect(messages.length).toBe(2);
expect(messages[0].message.content).toBe('Find my messages');
});

it('still lists sessions for directory-named (ASCII) projects', async () => {
const projectPath = path.join(tempRoot, 'workspace', 'plain-project');
const dirName = cliEncode(projectPath);
const sessionId = '11111111-2222-4333-8444-555555555555';

await writeClaudeSessionFile({ dirName, sessionId, cwd: projectPath });

const { projects } = await loadTestModules();
// Auto-discovered projects are named after the CLI directory itself.
const result = await projects.getSessions(dirName, 5, 0);
expect(result.total).toBe(1);
expect(result.sessions[0].id).toBe(sessionId);
});

it('merges sessions from multiple directories that resolve to the same path', async () => {
const projectPath = path.join(tempRoot, '项目', 'demo');
const sessionA = 'aaaaaaaa-1111-4111-8111-111111111111';
const sessionB = 'bbbbbbbb-2222-4222-8222-222222222222';

// Two historic encodings of the same cwd (e.g. the CLI changed its naming rule)
await writeClaudeSessionFile({ dirName: cliEncode(projectPath), sessionId: sessionA, cwd: projectPath });
await writeClaudeSessionFile({ dirName: `${cliEncode(projectPath)}-legacy`, sessionId: sessionB, cwd: projectPath });

const { projects } = await loadTestModules();
const projectName = projects.encodeProjectPath(projectPath);
await writeProjectConfig({
[projectName]: { manuallyAdded: true, originalPath: projectPath },
});

const result = await projects.getSessions(projectName, 10, 0);
const ids = result.sessions.map((session) => session.id).sort();
expect(ids).toEqual([sessionA, sessionB]);
});

it('discovers a directory created after the index was built once the cache is cleared', async () => {
const projectPath = path.join(tempRoot, '项目', 'fresh-project');
const sessionId = 'cccccccc-3333-4333-8333-333333333333';

const { projects } = await loadTestModules();
const projectName = projects.encodeProjectPath(projectPath);
await writeProjectConfig({
[projectName]: { manuallyAdded: true, originalPath: projectPath },
});

// First lookup: no CLI directory exists yet, builds an index without it
const before = await projects.getSessions(projectName, 5, 0);
expect(before.total).toBe(0);

await writeClaudeSessionFile({ dirName: cliEncode(projectPath), sessionId, cwd: projectPath });

// The file watcher calls this when ~/.claude/projects changes
projects.clearProjectDirectoryCache();

const after = await projects.getSessions(projectName, 5, 0);
expect(after.total).toBe(1);
expect(after.sessions[0].id).toBe(sessionId);
});

it('reconciles a targeted session even when the directory is newer than the index', async () => {
const projectPath = path.join(tempRoot, '项目', 'reconcile-project');
const sessionId = 'dddddddd-4444-4444-8444-444444444444';

const { projects, database } = await loadTestModules();
const projectName = projects.encodeProjectPath(projectPath);
await writeProjectConfig({
[projectName]: { manuallyAdded: true, originalPath: projectPath },
});

// Build the index before the CLI creates the directory (stale-index scenario)
await projects.getSessions(projectName, 5, 0);

await writeClaudeSessionFile({ dirName: cliEncode(projectPath), sessionId, cwd: projectPath });

// reconcileClaudeSessionIndex must retry with a fresh index instead of giving up
const result = await projects.reconcileClaudeSessionIndex(projectName, sessionId);
expect(result.session?.id).toBe(sessionId);

const indexed = database.sessionDb.getSessionById(sessionId);
expect(indexed?.provider).toBe('claude');
});
});
119 changes: 119 additions & 0 deletions server/__tests__/codex-nonascii-path.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile, readlink, stat } from 'fs/promises';
import os from 'os';
import path from 'path';

const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalDatabasePath = process.env.DATABASE_PATH;

let tempRoot = null;

async function loadModules() {
vi.resetModules();
const codexWorkingDir = await import('../utils/codexWorkingDir.js');
const projects = await import('../projects.js');
const database = await import('../database/db.js');
await database.initializeDatabase();
return { codexWorkingDir, projects, database };
}

function hasNonAscii(p) {
return /[^\x00-\x7F]/.test(p);
}

async function writeCodexRollout({ sessionId, cwd, userMessage = 'Hello', timestamp = '2026-06-09T11:00:00.000Z' }) {
const sessionFile = path.join(
tempRoot, '.codex', 'sessions', '2026', '06', '09',
`rollout-2026-06-09T11-00-00-${sessionId}.jsonl`,
);
await mkdir(path.dirname(sessionFile), { recursive: true });
const lines = [
{ timestamp, type: 'session_meta', payload: { id: sessionId, timestamp, cwd, model: 'gpt-5.5' } },
{ timestamp, type: 'event_msg', payload: { type: 'user_message', message: userMessage } },
{ timestamp, type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Hi' }] } },
].map((e) => JSON.stringify(e)).join('\n');
await writeFile(sessionFile, `${lines}\n`, 'utf8');
return sessionFile;
}

describe('Codex non-ASCII project path handling', () => {
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'dr-claw-codex-nonascii-'));
process.env.HOME = tempRoot;
process.env.USERPROFILE = tempRoot;
process.env.DATABASE_PATH = path.join(tempRoot, 'db', 'auth.db');
});

afterEach(async () => {
vi.resetModules();
if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = originalUserProfile;
if (originalDatabasePath === undefined) delete process.env.DATABASE_PATH; else process.env.DATABASE_PATH = originalDatabasePath;
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
}
});

it('returns ASCII project paths unchanged without creating a symlink', async () => {
const { codexWorkingDir } = await loadModules();
const asciiPath = path.join(tempRoot, 'workspace', 'plain-project');
await mkdir(asciiPath, { recursive: true });

const result = await codexWorkingDir.resolveCodexWorkingDirectory(asciiPath);
expect(result).toBe(asciiPath);

const shadowRoot = path.join(tempRoot, '.dr-claw', 'codex-cwd');
await expect(stat(shadowRoot)).rejects.toMatchObject({ code: 'ENOENT' });
});

it('maps a non-ASCII project path to an ASCII symlink pointing at the real dir', async () => {
const { codexWorkingDir } = await loadModules();
const realPath = path.join(tempRoot, '项目', '子目录', 'demo');
await mkdir(realPath, { recursive: true });

const result = await codexWorkingDir.resolveCodexWorkingDirectory(realPath);

expect(result).not.toBe(realPath);
expect(hasNonAscii(result)).toBe(false);
expect(codexWorkingDir.pathIsHeaderSafe(result)).toBe(true);

const target = await readlink(result);
expect(path.resolve(path.dirname(result), target)).toBe(path.resolve(realPath));

// A marker created through the symlink lands in the real directory
await writeFile(path.join(result, 'marker.txt'), 'ok', 'utf8');
await expect(stat(path.join(realPath, 'marker.txt'))).resolves.toBeTruthy();
});

it('is idempotent and stable for the same path', async () => {
const { codexWorkingDir } = await loadModules();
const realPath = path.join(tempRoot, '项目', 'demo');
await mkdir(realPath, { recursive: true });

const first = await codexWorkingDir.resolveCodexWorkingDirectory(realPath);
const second = await codexWorkingDir.resolveCodexWorkingDirectory(realPath);
expect(second).toBe(first);
});

it('associates a Codex session recorded under the shadow symlink with the real project', async () => {
const { codexWorkingDir, projects } = await loadModules();
const realPath = path.join(tempRoot, '项目', '子目录', 'demo');
await mkdir(realPath, { recursive: true });

// The shadow symlink Codex would run inside
const shadowPath = await codexWorkingDir.resolveCodexWorkingDirectory(realPath);
expect(shadowPath).not.toBe(realPath);

// Codex records the rollout with cwd = the shadow (symlink) path
const sessionId = 'a1b2c3d4-0000-4000-8000-cccccccccccc';
await writeCodexRollout({ sessionId, cwd: shadowPath, userMessage: 'Inspect the project' });

// dr-claw queries by the REAL project path; realpath matching must connect them
const sessions = await projects.getCodexSessions(realPath, { limit: 10 });
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe(sessionId);
expect(sessions[0].provider).toBe('codex');
});
});
42 changes: 22 additions & 20 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';

import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, resolveClaudeProjectDirs, clearProjectDirectoryCache } from './projects.js';
import { getProjectTokenUsageSummary } from './project-token-usage.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getCursorSessionStartTime, getActiveCursorSessions } from './cursor-cli.js';
Expand Down Expand Up @@ -2835,29 +2835,31 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
return res.status(500).json({ error: 'Failed to determine project path' });
}

// Construct the JSONL file path
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
// The encoding replaces /, spaces, ~, and _ with -
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
// Locate the session JSONL among the CLI session directories for this project
const projectDirs = await resolveClaudeProjectDirs(projectName, projectPath);

const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
let fileContent = null;
for (const projectDir of projectDirs) {
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);

// Constrain to projectDir
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}
// Constrain to projectDir
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return res.status(400).json({ error: 'Invalid path' });
}

// Read and parse the JSONL file
let fileContent;
try {
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
try {
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
break;
} catch (error) {
if (error.code !== 'ENOENT') {
throw error; // Re-throw other errors to be caught by outer try-catch
}
}
throw error; // Re-throw other errors to be caught by outer try-catch
}

if (fileContent === null) {
return res.status(404).json({ error: 'Session file not found' });
}
const lines = fileContent.trim().split('\n');

Expand Down
Loading
Loading