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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user