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