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
@@ -0,0 +1,52 @@
# 29 — the history layer is off `getOwnerHomeDir`
The owner asked me to carry on, so `27`'s "tomorrow" became tonight. This is the change I said I would not
make at this hour, made at this hour — review it accordingly, and I would rather you were slow about it than
quick.
## What moved
**`claude-sessions.ts` no longer knows how to invent a home.** `claudeHome(email)` is gone. Every transcript
read now takes a `ChatIdentity { email, home }`, and the home arrives resolved from `resolveHomeDir(userId)`.
The old line was `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')` — which discards its argument
whenever `HOME_DIR` is set, i.e. always on a real install. So every read resolved to the owner's `~/.claude`
regardless of who asked, and the comment above it asserted "single-user platform" as though that were a
property rather than an assumption.
Both halves travel together rather than one standing in for the other, because they are genuinely different:
`general_chat_sessions` lives under `DATA_PATH/<email>`, not under a home, so those paths stay email-derived.
Collapsing them would have been the same class of mistake as `undefined` meaning "the owner".
**`chat.ts` resolves the identity per request** (`chatIdentity`), and throws FORBIDDEN rather than falling
back — same posture as `resolveTurnIdentity`, for the same reason.
**`websocket.ts`'s `resolveCwd` takes a home rather than an email**, so `~` expands against the caller's own
home. Its old comment — *"the server owner is the only account, absolute paths are theirs to use"* — is
replaced with what is actually true now: an absolute path still passes through, and it is the kernel that
decides what a member's turn can open, not a string check here.
**Ordering worth checking:** identity is resolved *before* the cwd, because expanding `~` before knowing whose
home it is would be exactly the bug this removes. That reordering also let me delete a second
`resolveTurnIdentity` call that `25` had left further down the same function.
**`agent-runner.ts`** passes `{ email, home: homeDir }` explicitly — it is an owner-only path, and I would
rather it state that than inherit it.
## What I am least sure of
1. **`chatIdentity` throws where the old code could not fail.** Every `/chat` route now has a failure mode it
did not have. Owner-only today, and `resolveHomeDir` returns `isOwner` from a positive check — but it is
the kind of change that turns a database blip into a 403 on a screen that used to always load.
2. **`resolveBaseCwd` is exported.** I changed its parameter from email to home and followed the compiler; if
anything outside this diff passes an email, `tsgo` would have caught it, but the *meaning* of a wrong-but-
well-typed string would not be caught by anything.
3. **The regex pass.** I rewrote bare `email` references inside `chat.ts` mechanically and then repaired the
damage by hand. `tsgo` is clean and 78 tests pass, but that is a weaker guarantee than usual for this file.
`tsgo` clean, 78 tests, both gates up, `member` unreachable in production.
## What is left
Nothing I can write blind. `deprovisionOsAccount` is still mine and still tomorrow's. A member signing in, the
first member turn, and the gates are the owner's.
+9 -3
View File
@@ -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';
+41 -21
View File
@@ -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' });
});
+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);
+33 -21
View File
@@ -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);