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
+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);