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:
@@ -87,7 +87,7 @@ export function buildAgentPrompt(agent: AgentRecord, inputs: Record<string, unkn
|
||||
* which is fine: while a run is live you find it as the newest entry in the agent's project group.
|
||||
*/
|
||||
function titleRun(
|
||||
email: string,
|
||||
who: { email: string; home: string },
|
||||
cwd: string,
|
||||
claudeSessionId: string,
|
||||
agentName: string,
|
||||
@@ -99,7 +99,7 @@ function titleRun(
|
||||
const title = [agentName, subject, when].filter(Boolean).join(' · ');
|
||||
|
||||
try {
|
||||
if (!renameClaudeSession(email, cwd, claudeSessionId, title)) {
|
||||
if (!renameClaudeSession(who, cwd, claudeSessionId, title)) {
|
||||
logger.warn('Could not title agent run — transcript not found', { claudeSessionId, cwd });
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -171,7 +171,13 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
|
||||
run.finishedAt = Date.now();
|
||||
if (msg.claudeSessionId) {
|
||||
run.claudeSessionId = msg.claudeSessionId;
|
||||
titleRun(params.user.email, cwd, msg.claudeSessionId, agent.name || agent.dirName, inputs);
|
||||
titleRun(
|
||||
{ email: params.user.email, home: homeDir },
|
||||
cwd,
|
||||
msg.claudeSessionId,
|
||||
agent.name || agent.dirName,
|
||||
inputs,
|
||||
);
|
||||
}
|
||||
} else if (msg.type === 'error') {
|
||||
run.status = 'failed';
|
||||
|
||||
@@ -26,7 +26,26 @@ import {
|
||||
import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state';
|
||||
import { listChatModels } from './list-models';
|
||||
import { logger } from './logger';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import type { ChatIdentity } from './claude-sessions';
|
||||
import { readSttConfig } from '../server-settings/stt';
|
||||
|
||||
/**
|
||||
* Whose transcripts a request may read.
|
||||
*
|
||||
* The home comes from `resolveHomeDir`, never from `getOwnerHomeDir` — that one ignores its argument whenever
|
||||
* HOME_DIR is set, which is how every read in this router used to resolve to the owner's `~/.claude` no matter
|
||||
* who asked. Throws rather than falling back, for the same reason `resolveTurnIdentity` refuses: there is no
|
||||
* safe home to substitute, and the owner's is the one wrong answer.
|
||||
*
|
||||
* Unreachable by a member today — the router refuses non-owners above — so this is the path being made correct
|
||||
* before it is opened, not a live fix.
|
||||
*/
|
||||
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
const resolved = await resolveHomeDir(user.id);
|
||||
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
|
||||
return { email: user.email, home: resolved.home };
|
||||
}
|
||||
import { transcribeAudio } from '../stt/transcribe';
|
||||
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||
|
||||
@@ -60,17 +79,17 @@ chatRouter.use(async (ctx, next) => {
|
||||
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email);
|
||||
|
||||
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||
chatRouter.get('/pwds', (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
return ctx.json({ pwds: listClaudePwds(email), default: getGeneralChatSessionsCwd(email) });
|
||||
chatRouter.get('/pwds', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
return ctx.json({ pwds: listClaudePwds(who), default: getGeneralChatSessionsCwd(who.email) });
|
||||
});
|
||||
|
||||
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
|
||||
// (Claude transcripts + OpenCode's session store), newest first.
|
||||
chatRouter.get('/sessions', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const cwd = cwdOf(ctx, who.email);
|
||||
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
||||
const opencode = await listOpenCodeSessions(cwd);
|
||||
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return ctx.json({ sessions });
|
||||
@@ -83,14 +102,14 @@ chatRouter.get('/sessions', async (ctx) => {
|
||||
// response carries `total` (full length) and `offset` (absolute index of messages[0]) so the client knows
|
||||
// where the window sits and whether older messages remain above it.
|
||||
chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const cwd = cwdOf(ctx, who.email);
|
||||
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
|
||||
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
|
||||
const detail = isOpenCodeSessionId(id)
|
||||
? await loadOpenCodeSession(id)
|
||||
: (loadClaudeSession(email, cwd, id) ?? loadClaudeSessionById(email, id));
|
||||
: (loadClaudeSession(who, cwd, id) ?? loadClaudeSessionById(who, id));
|
||||
if (!detail) return ctx.text('Not found', 404);
|
||||
|
||||
const total = detail.messages.length;
|
||||
@@ -106,7 +125,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
// `detail.cwd` — the transcript's own directory — not the requested `cwd`, which on a deep link is
|
||||
// still the default group and holds none of this session's neighbours. OpenCode has no chains of its
|
||||
// own, so it gets neither rather than a fabricated answer.
|
||||
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(email, detail.cwd, id);
|
||||
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(who, detail.cwd, id);
|
||||
|
||||
return ctx.json({
|
||||
...detail,
|
||||
@@ -130,7 +149,8 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
// happens to be browsing — which is how the list ended up showing raw ids for anything running elsewhere.
|
||||
chatRouter.get('/live', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const email = user.email;
|
||||
const who = await chatIdentity(user);
|
||||
const email = who.email;
|
||||
// Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel:
|
||||
// both registry calls swallow their errors and return [].
|
||||
const [live, liveOpenCode] = await Promise.all([
|
||||
@@ -142,7 +162,7 @@ chatRouter.get('/live', async (ctx) => {
|
||||
// is named after Claude's. Null until the first turn reports one, which is a conversation that has
|
||||
// genuinely not been written yet.
|
||||
const transcriptId = session.claudeSessionId;
|
||||
const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(email, transcriptId) : null;
|
||||
const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(who, transcriptId) : null;
|
||||
return { ...session, harness: 'claude' as const, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null };
|
||||
});
|
||||
|
||||
@@ -192,24 +212,24 @@ chatRouter.get('/live', async (ctx) => {
|
||||
// Claude `/clear` chain that is every part of it: the list shows the chain as one conversation, so
|
||||
// deleting it deletes one conversation.
|
||||
chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(email, cwd, id);
|
||||
const cwd = cwdOf(ctx, who.email);
|
||||
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// PATCH /chat/sessions/:id/title[?cwd=] — rename in the owning harness's store.
|
||||
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const cwd = cwdOf(ctx, who.email);
|
||||
const { title } = await ctx.req.json<{ title?: string }>();
|
||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||
const ok = isOpenCodeSessionId(id)
|
||||
? await renameOpenCodeSession(id, title.trim())
|
||||
: renameClaudeSession(email, cwd, id, title.trim());
|
||||
: renameClaudeSession(who, cwd, id, title.trim());
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -220,9 +240,9 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||
// A task that has not written anything yet answers 200 with `{ kind: 'pending' }`, not 404. The tray asks
|
||||
// the moment `task:started` arrives, which is routinely before the file exists, and a 404 there would be
|
||||
// an error state for the most ordinary thing that can happen.
|
||||
chatRouter.get('/tasks/:id', (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const detail = loadBackgroundTask(email, ctx.req.param('id'));
|
||||
chatRouter.get('/tasks/:id', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const detail = loadBackgroundTask(who, ctx.req.param('id'));
|
||||
return ctx.json(detail ?? { kind: 'pending' });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -83,16 +83,25 @@ type WSData = {
|
||||
|
||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const resolveCwd = (email: string, cwd?: string) => {
|
||||
const root = getOwnerHomeDir(email);
|
||||
if (!cwd || cwd === '~') return root;
|
||||
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
||||
// The server owner is the only account — absolute paths are theirs to use.
|
||||
/**
|
||||
* Where a turn runs, relative to the caller's own home.
|
||||
*
|
||||
* `root` used to be `getOwnerHomeDir(email)`, which ignores its argument whenever HOME_DIR is set — so every
|
||||
* `~` expanded to the OWNER'S home regardless of who asked, and the comment here said "the server owner is
|
||||
* the only account" as though that were a property rather than an assumption.
|
||||
*
|
||||
* An absolute path is still passed through unchanged. That is not a hole: a member's turn runs as their Linux
|
||||
* account, so the kernel decides what it can open, and containment is `resolveUserPath`'s job in the file
|
||||
* browser rather than a string check here. But it is worth knowing it is the kernel doing the work.
|
||||
*/
|
||||
const resolveCwd = (home: string, cwd?: string) => {
|
||||
if (!cwd || cwd === '~') return home;
|
||||
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
||||
if (cwd.startsWith('/')) return cwd;
|
||||
return join(root, cwd);
|
||||
return join(home, cwd);
|
||||
};
|
||||
|
||||
export const resolveBaseCwd = (email: string, cwd?: string) => resolveCwd(email, cwd);
|
||||
export const resolveBaseCwd = (home: string, cwd?: string) => resolveCwd(home, cwd);
|
||||
|
||||
// The email chat runs from the selected account's storage dir:
|
||||
// DATA_PATH/<owner>/email_accounts/<accountEmail>
|
||||
@@ -119,10 +128,11 @@ async function resolveChatCwd(
|
||||
msg: { context?: string; contextId?: string; cwd?: string },
|
||||
email: string,
|
||||
userId: number,
|
||||
home: string,
|
||||
): Promise<string> {
|
||||
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
||||
if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(email, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
||||
return resolveCwd(email, msg.cwd);
|
||||
if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
||||
return resolveCwd(home, msg.cwd);
|
||||
}
|
||||
|
||||
const wsToSessionMap = new WeakMap<any, string>();
|
||||
@@ -342,7 +352,17 @@ async function handleClaudeCodeChat(
|
||||
): Promise<void> {
|
||||
const { email, username, userId } = ws.data;
|
||||
|
||||
const cwd = await resolveChatCwd(msg, email, userId);
|
||||
// Identity first: it decides both whose home `~` expands against and whose account the turn runs as, and
|
||||
// those must be the same answer. Resolving the cwd first would expand `~` before knowing whose it was.
|
||||
const identity = await resolveTurnIdentity(userId);
|
||||
if (identity.kind === 'refuse') {
|
||||
sendToClient(ws, { type: 'error', message: identity.reason });
|
||||
return;
|
||||
}
|
||||
|
||||
const home = identity.kind === 'member' ? identity.run.home : getOwnerHomeDir(email);
|
||||
|
||||
const cwd = await resolveChatCwd(msg, email, userId, home);
|
||||
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
@@ -380,16 +400,6 @@ async function handleClaudeCodeChat(
|
||||
const onMessage = createMessageHandler(sessionId, model);
|
||||
|
||||
try {
|
||||
// Whose account this turn runs as, resolved from the authenticated socket and never from the client
|
||||
// message — the same rule `server.tsx` applies to the pty sidecar. A `refuse` ends the turn here rather
|
||||
// than spawning anything, because the alternative to knowing is not "assume the owner".
|
||||
const identity = await resolveTurnIdentity(userId);
|
||||
if (identity.kind === 'refuse') {
|
||||
sendToClient(ws, { type: 'error', message: identity.reason });
|
||||
session.isGenerating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session._claudeKill) {
|
||||
// First turn of this session: open the persistent session + a SESSION-scoped event subscription
|
||||
// (survives turn-end so background task:notifications keep flowing). `kill` tears both down for an
|
||||
@@ -454,7 +464,9 @@ async function handleOpenCodeChat(
|
||||
): Promise<void> {
|
||||
const { email, username, userId } = ws.data;
|
||||
|
||||
const cwd = await resolveChatCwd(msg, email, userId);
|
||||
// The owner's home: opencode receives no identity at all (`TODO.md` → Multi-user), so this path is
|
||||
// owner-only and resolving anything else here would imply an isolation it does not have.
|
||||
const cwd = await resolveChatCwd(msg, email, userId, getOwnerHomeDir(email));
|
||||
|
||||
// Names this session in the Live panel until OpenCode gets round to titling it. First turn only.
|
||||
rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt);
|
||||
|
||||
Reference in New Issue
Block a user