From 95951fbe5a8adb598062d8870aba0c70e94b7a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 12 Aug 2026 00:48:09 +0000 Subject: [PATCH] resolve transcripts and cwd against the caller's home, not the owner's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/, 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 --- COMMS/sidecar-app-store/29-history-layer.md | 52 +++++++++++ src/servers/api/agents/agent-runner.ts | 12 ++- src/servers/api/chat/chat.ts | 62 ++++++++----- src/servers/api/chat/claude-sessions.ts | 98 ++++++++++++--------- src/servers/api/chat/websocket.ts | 54 +++++++----- 5 files changed, 191 insertions(+), 87 deletions(-) create mode 100644 COMMS/sidecar-app-store/29-history-layer.md diff --git a/COMMS/sidecar-app-store/29-history-layer.md b/COMMS/sidecar-app-store/29-history-layer.md new file mode 100644 index 00000000..93241fa4 --- /dev/null +++ b/COMMS/sidecar-app-store/29-history-layer.md @@ -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/`, 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. diff --git a/src/servers/api/agents/agent-runner.ts b/src/servers/api/agents/agent-runner.ts index 24d94f0b..8138c68b 100644 --- a/src/servers/api/agents/agent-runner.ts +++ b/src/servers/api/agents/agent-runner.ts @@ -87,7 +87,7 @@ export function buildAgentPrompt(agent: AgentRecord, inputs: Record { + 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/ 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' }); }); diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 431f35c3..943db39d 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -18,11 +18,25 @@ import { DATA_PATH } from '../../data-path'; // The `claude` CLI persists every session as a JSONL transcript at // $HOME/.claude/projects//.jsonl // where 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/, 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(); 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); diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 88abd840..61c7b477 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -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//email_accounts/ @@ -119,10 +128,11 @@ async function resolveChatCwd( msg: { context?: string; contextId?: string; cwd?: string }, email: string, userId: number, + home: string, ): Promise { 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(); @@ -342,7 +352,17 @@ async function handleClaudeCodeChat( ): Promise { 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 { 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);