reach a member's transcripts by listing them, not just by reading them

yesterday's fix routed transcript CONTENT through the member's identity and
stopped there, on the strength of a comment in ChatIdentity saying enumeration
never needed it — "their directories are 775 and the platform holds an ACL
entry, so readdirSync and statSync have always worked".

there are no 775 directories on this path. claude creates ~/.claude/projects/
and every project group at mode 700, and a 700 directory clamps the ACL mask to
--- exactly as a 600 file does:

    user:officer:rwx    #effective:---
    mask::---

measured against a real member home:

    existsSync(projects)        -> true     (stat only needs traverse on .claude)
    readdirSync(projects)       -> EACCES
    existsSync(projects/<slug>) -> false
    statSync(<transcript>)      -> EACCES

existsSync answering false rather than throwing is why this was invisible: every
caller read it as "no such session". one root cause, three reported symptoms —
an empty conversation list, no title on a new chat, and a /chat/<id> deep link
that never restored the conversation. a fourth nobody had reported yet: delete
removed nothing and still answered ok, because unlink needs w+x on the group
directory too.

so enumeration goes through the same door as content, as ONE call rather than a
spawn per entry: listTranscriptsAs runs a single `find` as the member and
returns every transcript with its mtime, which readdir+stat could not do without
dozens of setpriv forks per request and a matching pile of auth.log lines. the
owner keeps a fork-free path — that process already IS the owner. removeAs does
the same for unlink, and readTailAs no longer stats a file it cannot stat.

summarizeTranscript now takes the mtime it is given instead of stat'ing again,
which is both the fix and one less syscall per file.

verified against jg@pertento.ai on this machine: 6 conversations listed with
titles from their first prompts, and a deep link by id alone loads 71 messages.
owner path re-checked and unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 19:48:06 +00:00
co-authored by Claude Opus 5
parent 9f903479ce
commit 327783532e
2 changed files with 253 additions and 124 deletions
+100 -111
View File
@@ -1,7 +1,15 @@
import { readdirSync, existsSync, statSync, mkdirSync, rmSync, realpathSync } from 'node:fs';
import { realpathSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import { appendTextAs, readHeadAs, readTailAs, readTextAs, type AsUser } from '../../read-as-user';
import {
appendTextAs,
listTranscriptsAs,
readHeadAs,
readTailAs,
readTextAs,
removeAs,
type AsUser,
} from '../../read-as-user';
// ── Claude session store (source of truth) ──
// The `claude` CLI persists every session as a JSONL transcript at
@@ -31,12 +39,13 @@ export type ChatIdentity = {
/** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */
home: string;
/**
* Whose identity to read transcript CONTENT as — `null` for the owner. From `resolveHomeDir`.
* Whose identity to read a member's transcripts as — `null` for the owner. From `resolveHomeDir`.
*
* Locating a member's transcripts never needed this: their directories are 775 and the platform holds an
* ACL entry, so `readdirSync` and `statSync` have always worked. Reading one does, because `claude` writes
* every transcript at mode 600 and that clamps the ACL mask to `---`. See `read-as-user.ts` — this field
* is the whole reason a member's chat list was empty while their chat worked.
* This said locating them "never needed this: their directories are 775". There are no 775 directories on
* this path — `claude` creates `projects/` and every group at 700, which clamps the platform's ACL entry
* to nothing exactly as mode 600 does for the files. So `readdirSync`, `statSync` and unlink all fail too,
* and `existsSync` answers **false** rather than throwing, which is why it read as "no such session"
* everywhere instead of as an error. See `read-as-user.ts`; the listing goes through `listTranscriptsAs`.
*/
osUser: string | null;
/**
@@ -51,6 +60,28 @@ export type ChatIdentity = {
const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects');
/**
* Where a session's transcript actually is, or null.
*
* One listing as the transcripts' owner, then a lookup — never `existsSync` on a candidate path. For a
* member `existsSync` answers **false** on a file that is plainly there, because the group directory is
* mode 700 and the service user cannot traverse it, and every caller read that false as "no such session".
*
* `cwd` names the group to prefer, not the group to trust: a session's group and the caller's current one
* disagree routinely (a deep link has not resolved its group yet, or the list is showing another). Reads
* have always fallen back like this; writes did not, so delete and rename returned "not found" for a
* session that was on screen.
*/
function locateTranscript(who: ChatIdentity, sessionId: string, cwd?: string): string | null {
const projectsDir = claudeProjectsDir(who.home);
const files = listTranscriptsAs(who.osUser, projectsDir);
const preferred = cwd ? projectSlug(cwd) : null;
const hit =
(preferred && files.find((f) => f.id === sessionId && f.slug === preferred)) ||
files.find((f) => f.id === sessionId);
return hit ? join(projectsDir, hit.slug, `${sessionId}.jsonl`) : null;
}
/** Claude's folder name for a working directory. */
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
@@ -132,16 +163,11 @@ type Entry = {
*/
const summaryCache = new Map<string, { mtimeMs: number; summary: TranscriptSummary }>();
function summarizeTranscript(osUser: AsUser, filePath: string, id: string): TranscriptSummary | null {
let mtimeMs: number;
let mtime: string;
try {
const stat = statSync(filePath);
mtimeMs = stat.mtimeMs;
mtime = stat.mtime.toISOString();
} catch {
return null;
}
// `mtimeMs` is passed in rather than stat'ed here: `statSync` is EACCES on a member's transcript, and the
// listing that found the file already carries its mtime. Stat'ing again would be a second fork per file AND
// would fail for exactly the accounts this exists to serve.
function summarizeTranscript(osUser: AsUser, filePath: string, id: string, mtimeMs: number): TranscriptSummary | null {
const mtime = new Date(mtimeMs).toISOString();
const cached = summaryCache.get(filePath);
if (cached && cached.mtimeMs === mtimeMs) return cached.summary;
@@ -430,8 +456,9 @@ function parseClaudeTranscript(
sessionId: string,
fallbackCwd = '',
): ClaudeSessionDetail | null {
if (!existsSync(filePath)) return null;
// No `existsSync` guard: it is redundant with the catch below (a missing file throws there just the same)
// and it is actively WRONG for a member, answering false on a transcript that exists — which is how a
// /chat/<id> deep link 404'd on a session the member was looking at.
const messages: ClaudeChatMessage[] = [];
const toolById = new Map<string, Extract<ClaudeChatMessage, { role: 'tool' }>>();
let model = '';
@@ -570,20 +597,10 @@ export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: str
* refresh to /chat/<id>, when the cwd isn't known yet; the transcript records the real cwd, which the
* caller uses to scope the list + cwd picker. */
export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): ClaudeSessionDetail | null {
const projectsDir = claudeProjectsDir(who.home);
let slugs: string[];
try {
slugs = readdirSync(projectsDir);
} catch {
return null;
}
for (const slug of slugs) {
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
if (!existsSync(filePath)) continue;
const detail = parseClaudeTranscript(who.osUser, filePath, sessionId);
return detail && loadChainTranscript(who, detail);
}
return null;
const filePath = locateTranscript(who, sessionId);
if (!filePath) return null;
const detail = parseClaudeTranscript(who.osUser, filePath, sessionId);
return detail && loadChainTranscript(who, detail);
}
/**
@@ -593,23 +610,8 @@ export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): Cla
* or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did
* not, so delete and rename returned "not found" for a session that was plainly on screen.
*/
function findTranscript(who: ChatIdentity, cwd: string, sessionId: string): string | null {
const preferred = join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`);
if (existsSync(preferred)) return preferred;
const projectsDir = claudeProjectsDir(who.home);
let slugs: string[];
try {
slugs = readdirSync(projectsDir);
} catch {
return null;
}
for (const slug of slugs) {
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
if (existsSync(filePath)) return filePath;
}
return null;
}
const findTranscript = (who: ChatIdentity, cwd: string, sessionId: string): string | null =>
locateTranscript(who, sessionId, cwd);
/**
* Delete a conversation by removing its transcript file — and, when it is a `/clear` chain, the files
@@ -625,11 +627,11 @@ export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: s
const ownCwd = firstCwd(who.osUser, filePath);
const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
// `removeAs`, not `rmSync`: unlinking needs `w`+`x` on the DIRECTORY, which the service user does not have
// on a member's group. `rmSync` guarded by `existsSync` therefore deleted nothing and still reported
// success — the conversation reappeared on the next refresh.
const dir = dirname(filePath);
for (const id of ids) {
const partPath = join(dir, `${id}.jsonl`);
if (existsSync(partPath)) rmSync(partPath);
}
for (const id of ids) removeAs(who.osUser, join(dir, `${id}.jsonl`));
return true;
}
@@ -773,30 +775,30 @@ export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
const defaultCwd = who.home;
const byCwd = new Map<string, { count: number; updatedAt: string }>();
if (existsSync(projectsDir)) {
for (const group of readdirSync(projectsDir)) {
const groupDir = join(projectsDir, group);
let files: string[];
try {
files = readdirSync(groupDir).filter((f) => f.endsWith('.jsonl'));
} catch {
continue; // not a directory
}
if (files.length === 0) continue;
// One listing for the whole tree, grouped here. This used to be `readdirSync` + a `statSync` per file,
// and BOTH are EACCES for a member — the readdir threw uncaught, so this endpoint answered 500 rather
// than answering wrongly. That 500 was the visible half of the empty conversation list.
const byGroup = new Map<string, { count: number; newest: number; first: string }>();
for (const file of listTranscriptsAs(who.osUser, projectsDir)) {
const prev = byGroup.get(file.slug);
byGroup.set(file.slug, {
count: (prev?.count ?? 0) + 1,
newest: Math.max(prev?.newest ?? 0, file.mtimeMs),
first: prev?.first ?? file.id,
});
}
const cwd = firstCwd(who.osUser, join(groupDir, files[0]!));
if (!cwd) continue;
let updatedAt = '';
for (const f of files) {
const m = statSync(join(groupDir, f)).mtime.toISOString();
if (m > updatedAt) updatedAt = m;
}
const prev = byCwd.get(cwd);
byCwd.set(cwd, {
count: (prev?.count ?? 0) + files.length,
updatedAt: prev && prev.updatedAt > updatedAt ? prev.updatedAt : updatedAt,
});
}
for (const [slug, group] of byGroup) {
// The cwd is a property of the transcript's entries, not of the slug, which is lossy and cannot be
// reversed. Any file in the group answers it.
const cwd = firstCwd(who.osUser, join(projectsDir, slug, `${group.first}.jsonl`));
if (!cwd) continue;
const updatedAt = new Date(group.newest).toISOString();
const prev = byCwd.get(cwd);
byCwd.set(cwd, {
count: (prev?.count ?? 0) + group.count,
updatedAt: prev && prev.updatedAt > updatedAt ? prev.updatedAt : updatedAt,
});
}
if (!byCwd.has(defaultCwd)) byCwd.set(defaultCwd, { count: 0, updatedAt: '' });
@@ -814,13 +816,12 @@ export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
* mtime-cached, which is what makes calling this on every request cheap.
*/
function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
const dir = join(claudeProjectsDir(who.home), projectSlug(cwd));
if (!existsSync(dir)) return [];
const slug = projectSlug(cwd);
const dir = join(claudeProjectsDir(who.home), slug);
const sessions: TranscriptSummary[] = [];
for (const file of readdirSync(dir)) {
if (!file.endsWith('.jsonl')) continue;
const summary = summarizeTranscript(who.osUser, join(dir, file), file.replace(/\.jsonl$/, ''));
for (const file of listTranscriptsAs(who.osUser, claudeProjectsDir(who.home), slug)) {
const summary = summarizeTranscript(who.osUser, join(dir, `${file.id}.jsonl`), file.id, file.mtimeMs);
if (summary) sessions.push(summary);
}
return applyLineage(sessions);
@@ -869,40 +870,28 @@ export function claudeSessionContext(
* anywhere hotter.
*/
export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: string; cwd: string } | null {
const projectsDir = claudeProjectsDir(who.home);
let slugs: string[];
const filePath = locateTranscript(who, sessionId);
if (!filePath) return null;
// The cwd is a property of the transcript's entries, so the first one carrying it settles which group
// this session belongs to — no need to reverse the slug, which is lossy.
let cwd: string | null = null;
try {
slugs = readdirSync(projectsDir);
for (const line of readTextAs(who.osUser, filePath).split('\n')) {
if (!line.trim()) continue;
const entry = JSON.parse(line) as { cwd?: string };
if (entry.cwd) {
cwd = entry.cwd;
break;
}
}
} catch {
return null;
}
if (!cwd) return null;
for (const slug of slugs) {
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
if (!existsSync(filePath)) continue;
// The cwd is a property of the transcript's entries, so the first one carrying it settles which
// group this session belongs to — no need to reverse the slug, which is lossy.
let cwd: string | null = null;
try {
for (const line of readTextAs(who.osUser, filePath).split('\n')) {
if (!line.trim()) continue;
const entry = JSON.parse(line) as { cwd?: string };
if (entry.cwd) {
cwd = entry.cwd;
break;
}
}
} catch {
return null;
}
if (!cwd) return null;
const context = claudeSessionContext(who, cwd, sessionId);
return context ? { title: context.title, cwd } : null;
}
return null;
const context = claudeSessionContext(who, cwd, sessionId);
return context ? { title: context.title, cwd } : null;
}
/**