resolve transcripts and cwd against the caller's home, not the owner's

The history layer, and the last change that could be made without a live member.

claude-sessions.ts had `claudeHome = process.env.HOME_DIR ?? join(DATA_PATH,
email, 'home')`, which discards its argument whenever HOME_DIR is set — always,
on a real install. Every transcript read therefore resolved to the OWNER'S
~/.claude no matter who asked, and the comment above it asserted "single-user
platform" as though that were a property rather than an assumption. A member
reaching these functions would have been handed the owner's conversation list.

Now every read takes a ChatIdentity {email, home} with the home resolved from
resolveHomeDir(userId), and this file has no way to invent one. Both fields
travel together because they are genuinely different: general_chat_sessions
lives under DATA_PATH/<email>, not under a home. Collapsing them would be the
same class of mistake as undefined meaning "the owner".

websocket.ts's resolveCwd takes a home, so `~` expands against the caller's own.
Identity is resolved BEFORE the cwd — expanding `~` before knowing whose home it
is would be exactly the bug being removed — which also let a duplicate
resolveTurnIdentity call from 6aeb304 be deleted.

chat.ts resolves per request and throws FORBIDDEN rather than falling back, same
posture as resolveTurnIdentity. agent-runner passes the owner's home explicitly
rather than inheriting it, since that path really is owner-only.

Made at 01:00 after saying it should not be. Three things I am least sure of are
listed in COMMS 29 rather than left for the reviewer to find: chat routes now
have a failure mode they did not have, resolveBaseCwd's exported parameter
changed meaning rather than shape, and the bare-email rewrite in chat.ts was
mechanical with hand repair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 00:48:09 +00:00
co-authored by Claude Opus 5
parent 519109a342
commit 95951fbe5a
5 changed files with 191 additions and 87 deletions
+56 -42
View File
@@ -18,11 +18,25 @@ import { DATA_PATH } from '../../data-path';
// The `claude` CLI persists every session as a JSONL transcript at
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
// where <slug> is the working directory with every non-alphanumeric char replaced by '-'.
// Single-user platform: Claude runs with no isolation — HOME is the real home
// (HOME_DIR) — so its transcripts are the same store the terminal `claude` uses. We never keep our
// own copy; Claude's files are authoritative.
const claudeHome = (email: string): string => process.env.HOME_DIR ?? join(DATA_PATH, email, 'home');
// Claude runs with no isolation for the OWNER — HOME is their real home — so its transcripts are the same
// store their terminal `claude` uses. We never keep our own copy; Claude's files are authoritative.
//
// ── Why this takes a home instead of an email ──
//
// It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discards its argument whenever
// HOME_DIR is set — which is always, on a real install. Every read therefore resolved to the OWNER'S
// transcripts regardless of who was asking, and the comment above it said "single-user platform" as though
// that were a property rather than an assumption. A member reaching these functions would have been handed the
// owner's conversation list.
//
// So the home arrives resolved, from `resolveHomeDir(userId)`, and this file has no way to invent one. The
// email is still passed where a path is genuinely email-derived — `general_chat_sessions` lives under
// DATA_PATH, not under a home — which is why both travel together rather than one standing in for the other.
export type ChatIdentity = {
email: string;
/** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */
home: string;
};
/** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */
export const getGeneralChatSessionsCwd = (email: string): string => join(DATA_PATH, email, 'general_chat_sessions');
@@ -34,7 +48,7 @@ export const ensureGeneralChatSessionsCwd = (email: string): string => {
return dir;
};
const claudeProjectsDir = (email: string): string => join(claudeHome(email), '.claude', 'projects');
const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects');
/** Claude's folder name for a working directory. */
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
@@ -506,15 +520,15 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd
* Pagination needs nothing: the client asks for index windows into whatever the server calls the
* transcript, so a longer one simply pages further back.
*/
function loadChainTranscript(email: string, detail: ClaudeSessionDetail): ClaudeSessionDetail {
function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): ClaudeSessionDetail {
const parts = (() => {
const group = scanGroup(email, detail.cwd);
const group = scanGroup(who, detail.cwd);
const head = group.find((session) => session.id === detail.id);
return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : [];
})();
if (parts.length < 2) return detail;
const dir = join(claudeProjectsDir(email), projectSlug(detail.cwd));
const dir = join(claudeProjectsDir(who.home), projectSlug(detail.cwd));
const earlier: ClaudeChatMessage[] = [];
for (const part of parts.slice(0, -1)) {
const segment = parseClaudeTranscript(join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
@@ -526,20 +540,20 @@ function loadChainTranscript(email: string, detail: ClaudeSessionDetail): Claude
}
/** Load a session when its cwd (project group) is known. */
export function loadClaudeSession(email: string, cwd: string, sessionId: string): ClaudeSessionDetail | null {
export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): ClaudeSessionDetail | null {
const detail = parseClaudeTranscript(
join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`),
join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`),
sessionId,
cwd,
);
return detail && loadChainTranscript(email, detail);
return detail && loadChainTranscript(who, detail);
}
/** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link /
* 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(email: string, sessionId: string): ClaudeSessionDetail | null {
const projectsDir = claudeProjectsDir(email);
export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): ClaudeSessionDetail | null {
const projectsDir = claudeProjectsDir(who.home);
let slugs: string[];
try {
slugs = readdirSync(projectsDir);
@@ -550,7 +564,7 @@ export function loadClaudeSessionById(email: string, sessionId: string): ClaudeS
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
if (!existsSync(filePath)) continue;
const detail = parseClaudeTranscript(filePath, sessionId);
return detail && loadChainTranscript(email, detail);
return detail && loadChainTranscript(who, detail);
}
return null;
}
@@ -562,11 +576,11 @@ export function loadClaudeSessionById(email: string, sessionId: string): ClaudeS
* 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(email: string, cwd: string, sessionId: string): string | null {
const preferred = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
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(email);
const projectsDir = claudeProjectsDir(who.home);
let slugs: string[];
try {
slugs = readdirSync(projectsDir);
@@ -588,12 +602,12 @@ function findTranscript(email: string, cwd: string, sessionId: string): string |
* routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
* part of several.
*/
export function deleteClaudeSession(email: string, cwd: string, sessionId: string): boolean {
const filePath = findTranscript(email, cwd, sessionId);
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): boolean {
const filePath = findTranscript(who, cwd, sessionId);
if (!filePath) return false;
const ownCwd = firstCwd(filePath);
const ids = ownCwd ? chainFileIds(email, ownCwd, sessionId) : [sessionId];
const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
const dir = dirname(filePath);
for (const id of ids) {
const partPath = join(dir, `${id}.jsonl`);
@@ -607,8 +621,8 @@ export function deleteClaudeSession(email: string, cwd: string, sessionId: strin
* the title lives in .claude (source of truth). Our reader takes the last summary as the title; no
* timestamp is written so the rename doesn't reorder the list.
*/
export function renameClaudeSession(email: string, cwd: string, sessionId: string, title: string): boolean {
const filePath = findTranscript(email, cwd, sessionId);
export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: string, title: string): boolean {
const filePath = findTranscript(who, cwd, sessionId);
if (!filePath) return false;
// Attach the summary to the transcript's tip (the last entry carrying a uuid).
@@ -661,11 +675,11 @@ export type BackgroundTaskDetail =
| { kind: 'agent'; messages: ClaudeChatMessage[] }
| { kind: 'log'; text: string; truncated: boolean };
function findTaskOutput(email: string, taskId: string): string | null {
function findTaskOutput(who: ChatIdentity, taskId: string): string | null {
const tmpRoot = process.env.TMPDIR ?? '/tmp';
const candidates: [string, string][] = [
[tmpRoot, `claude-*/*/*/tasks/${taskId}.output`],
[claudeProjectsDir(email), `*/*/subagents/agent-${taskId}.jsonl`],
[claudeProjectsDir(who.home), `*/*/subagents/agent-${taskId}.jsonl`],
];
for (const [root, pattern] of candidates) {
try {
@@ -701,9 +715,9 @@ function tailFile(filePath: string, bytes: number): { text: string; truncated: b
* What a background task is doing right now. Returns null when nothing has been written yet — which is
* the normal state for the first second or two of a task's life, not an error.
*/
export function loadBackgroundTask(email: string, taskId: string): BackgroundTaskDetail | null {
export function loadBackgroundTask(who: ChatIdentity, taskId: string): BackgroundTaskDetail | null {
if (!TASK_ID_RE.test(taskId)) return null;
const found = findTaskOutput(email, taskId);
const found = findTaskOutput(who, taskId);
if (!found) return null;
let target = found;
@@ -754,9 +768,9 @@ function firstCwd(filePath: string): string {
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
/** All working directories that have Claude sessions, plus the default /chat dir. Newest first. */
export function listClaudePwds(email: string): ClaudePwd[] {
const projectsDir = claudeProjectsDir(email);
const defaultCwd = getGeneralChatSessionsCwd(email);
export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
const projectsDir = claudeProjectsDir(who.home);
const defaultCwd = getGeneralChatSessionsCwd(who.email);
const byCwd = new Map<string, { count: number; updatedAt: string }>();
if (existsSync(projectsDir)) {
@@ -799,8 +813,8 @@ export function listClaudePwds(email: string): ClaudePwd[] {
* to its neighbours, so there is no per-file answer to cache. The per-file summaries underneath it are
* mtime-cached, which is what makes calling this on every request cheap.
*/
function scanGroup(email: string, cwd: string): TranscriptSummary[] {
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
const dir = join(claudeProjectsDir(who.home), projectSlug(cwd));
if (!existsSync(dir)) return [];
const sessions: TranscriptSummary[] = [];
@@ -813,8 +827,8 @@ function scanGroup(email: string, cwd: string): TranscriptSummary[] {
}
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
return mergeChains(scanGroup(email, cwd))
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
return mergeChains(scanGroup(who, cwd))
.map(publish)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
@@ -831,13 +845,13 @@ export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSum
* still opens a real transcript, so answer for the part itself rather than 404 the title.
*/
export function claudeSessionContext(
email: string,
who: ChatIdentity,
cwd: string,
sessionId: string,
): { title: string; partCount: number } | null {
const merged = listClaudeSessions(email, cwd).find((entry) => entry.id === sessionId);
const merged = listClaudeSessions(who, cwd).find((entry) => entry.id === sessionId);
if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 };
const part = scanGroup(email, cwd).find((entry) => entry.id === sessionId);
const part = scanGroup(who, cwd).find((entry) => entry.id === sessionId);
return part ? { title: part.title, partCount: 1 } : null;
}
@@ -854,8 +868,8 @@ export function claudeSessionContext(
* of live sessions, so it is not worth a cache yet — but it is worth knowing before this is called from
* anywhere hotter.
*/
export function liveSessionTitle(email: string, sessionId: string): { title: string; cwd: string } | null {
const projectsDir = claudeProjectsDir(email);
export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: string; cwd: string } | null {
const projectsDir = claudeProjectsDir(who.home);
let slugs: string[];
try {
slugs = readdirSync(projectsDir);
@@ -884,7 +898,7 @@ export function liveSessionTitle(email: string, sessionId: string): { title: str
}
if (!cwd) return null;
const context = claudeSessionContext(email, cwd, sessionId);
const context = claudeSessionContext(who, cwd, sessionId);
return context ? { title: context.title, cwd } : null;
}
@@ -897,8 +911,8 @@ export function liveSessionTitle(email: string, sessionId: string): { title: str
* the ancestors behind would resurrect them as separate rows the moment their child was gone, which
* reads as the delete having half worked.
*/
function chainFileIds(email: string, cwd: string, sessionId: string): string[] {
const group = scanGroup(email, cwd);
function chainFileIds(who: ChatIdentity, cwd: string, sessionId: string): string[] {
const group = scanGroup(who, cwd);
const head = group.find((session) => session.id === sessionId);
if (!head) return [sessionId];
return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id);