diff --git a/src/servers/api/agents/agent-runner.ts b/src/servers/api/agents/agent-runner.ts index 8138c68b..d45c42db 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 }; + return { email: user.email, home: resolved.home, osUser: resolved.osUser }; } import { transcribeAudio } from '../stt/transcribe'; import { registerAgentPanelRoutes } from './agent-panels-routes'; diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index b26d5222..7eeab373 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -1,18 +1,7 @@ -import { - readdirSync, - readFileSync, - existsSync, - statSync, - mkdirSync, - rmSync, - appendFileSync, - openSync, - readSync, - closeSync, - realpathSync, -} from 'node:fs'; +import { readdirSync, existsSync, statSync, mkdirSync, rmSync, realpathSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { DATA_PATH } from '../../data-path'; +import { appendTextAs, readHeadAs, readTailAs, readTextAs, type AsUser } from '../../read-as-user'; // ── Claude session store (source of truth) ── // The `claude` CLI persists every session as a JSONL transcript at @@ -41,6 +30,15 @@ export type ChatIdentity = { email: string; /** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */ home: string; + /** + * Whose identity to read transcript CONTENT as — `null` for the owner. From `resolveHomeDir`. + * + * Locating a member's transcripts never needed this: their directories are 775 and the platform holds an + * ACL entry, so `readdirSync` and `statSync` have always worked. Reading one does, because `claude` writes + * every transcript at mode 600 and that clamps the ACL mask to `---`. See `read-as-user.ts` — this field + * is the whole reason a member's chat list was empty while their chat worked. + */ + osUser: string | null; }; const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects'); @@ -126,7 +124,7 @@ type Entry = { */ const summaryCache = new Map(); -function summarizeTranscript(filePath: string, id: string): TranscriptSummary | null { +function summarizeTranscript(osUser: AsUser, filePath: string, id: string): TranscriptSummary | null { let mtimeMs: number; let mtime: string; try { @@ -141,7 +139,7 @@ function summarizeTranscript(filePath: string, id: string): TranscriptSummary | let raw: string; try { - raw = readFileSync(filePath, 'utf-8'); + raw = readTextAs(osUser, filePath); } catch { return null; } @@ -418,7 +416,12 @@ function userMessageFrom(text: string): ClaudeChatMessage | null { export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] }; /** Parse a session's JSONL transcript into a flat, display-ready message list. Claude is the source. */ -function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd = ''): ClaudeSessionDetail | null { +function parseClaudeTranscript( + osUser: AsUser, + filePath: string, + sessionId: string, + fallbackCwd = '', +): ClaudeSessionDetail | null { if (!existsSync(filePath)) return null; const messages: ClaudeChatMessage[] = []; @@ -426,7 +429,17 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd let model = ''; let sessionCwd = fallbackCwd; - for (const line of readFileSync(filePath, 'utf-8').split('\n')) { + // `existsSync` above passes for a member's transcript and the read still fails — the file is theirs at + // mode 600. That combination used to escape as a 500 from `GET /chat/sessions/:id`, because only the + // list path caught its read. "Not found" is the honest answer for a transcript we cannot open. + let raw: string; + try { + raw = readTextAs(osUser, filePath); + } catch { + return null; + } + + for (const line of raw.split('\n')) { if (!line.trim()) continue; let entry: Entry & { message?: { role?: string; content?: unknown; model?: string } }; try { @@ -526,7 +539,7 @@ function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): Cl 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); + const segment = parseClaudeTranscript(who.osUser, join(dir, `${part.id}.jsonl`), part.id, detail.cwd); if (!segment) continue; earlier.push(...segment.messages, { role: 'divider', sessionId: part.id }); } @@ -537,6 +550,7 @@ function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): Cl /** Load a session when its cwd (project group) is known. */ export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): ClaudeSessionDetail | null { const detail = parseClaudeTranscript( + who.osUser, join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`), sessionId, cwd, @@ -558,7 +572,7 @@ export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): Cla for (const slug of slugs) { const filePath = join(projectsDir, slug, `${sessionId}.jsonl`); if (!existsSync(filePath)) continue; - const detail = parseClaudeTranscript(filePath, sessionId); + const detail = parseClaudeTranscript(who.osUser, filePath, sessionId); return detail && loadChainTranscript(who, detail); } return null; @@ -601,7 +615,7 @@ export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: s const filePath = findTranscript(who, cwd, sessionId); if (!filePath) return false; - const ownCwd = firstCwd(filePath); + const ownCwd = firstCwd(who.osUser, filePath); const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId]; const dir = dirname(filePath); for (const id of ids) { @@ -622,7 +636,7 @@ export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: s // Attach the summary to the transcript's tip (the last entry carrying a uuid). let leafUuid = sessionId; - const lines = readFileSync(filePath, 'utf-8').split('\n'); + const lines = readTextAs(who.osUser, filePath).split('\n'); for (let i = lines.length - 1; i >= 0; i--) { if (!lines[i]!.trim()) continue; try { @@ -636,7 +650,7 @@ export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: s } } - appendFileSync(filePath, `${JSON.stringify({ type: 'summary', summary: title, leafUuid })}\n`); + appendTextAs(who.osUser, filePath, `${JSON.stringify({ type: 'summary', summary: title, leafUuid })}\n`); return true; } @@ -689,21 +703,10 @@ function findTaskOutput(who: ChatIdentity, taskId: string): string | null { } /** The tail of a file, as text, without reading the whole thing. */ -function tailFile(filePath: string, bytes: number): { text: string; truncated: boolean } { - const size = statSync(filePath).size; - const start = Math.max(0, size - bytes); - let fd: number | undefined; - try { - fd = openSync(filePath, 'r'); - const buf = Buffer.alloc(size - start); - const n = readSync(fd, buf, 0, buf.length, start); - let text = buf.toString('utf-8', 0, n); - // A byte offset lands mid-line (and possibly mid-codepoint); drop the partial head. - if (start > 0) text = text.slice(text.indexOf('\n') + 1); - return { text, truncated: start > 0 }; - } finally { - if (fd !== undefined) closeSync(fd); - } +function tailFile(osUser: AsUser, filePath: string, bytes: number): { text: string; truncated: boolean } { + const { text, truncated } = readTailAs(osUser, filePath, bytes); + // A byte offset lands mid-line (and possibly mid-codepoint); drop the partial head. + return { text: truncated ? text.slice(text.indexOf('\n') + 1) : text, truncated }; } /** @@ -723,7 +726,7 @@ export function loadBackgroundTask(who: ChatIdentity, taskId: string): Backgroun } if (target.endsWith('.jsonl')) { - const detail = parseClaudeTranscript(target, taskId); + const detail = parseClaudeTranscript(who.osUser, target, taskId); if (!detail) return null; const messages = detail.messages.map((m) => m.role === 'tool' && m.output && m.output.length > OUTPUT_CAP @@ -734,7 +737,7 @@ export function loadBackgroundTask(who: ChatIdentity, taskId: string): Backgroun } try { - const { text, truncated } = tailFile(target, LOG_TAIL_BYTES); + const { text, truncated } = tailFile(who.osUser, target, LOG_TAIL_BYTES); return { kind: 'log', text, truncated }; } catch { return null; @@ -746,17 +749,11 @@ export function loadBackgroundTask(who: ChatIdentity, taskId: string): Backgroun // real `cwd` back from each group's transcripts so the UI can offer "jump to any project's sessions". /** Read the `cwd` recorded in a transcript, from a bounded head read (cwd appears in early entries). */ -function firstCwd(filePath: string): string { - let fd: number | undefined; +function firstCwd(osUser: AsUser, filePath: string): string { try { - fd = openSync(filePath, 'r'); - const buf = Buffer.alloc(32768); - const n = readSync(fd, buf, 0, buf.length, 0); - return buf.toString('utf-8', 0, n).match(/"cwd":"([^"]*)"/)?.[1] ?? ''; + return readHeadAs(osUser, filePath, 32768).match(/"cwd":"([^"]*)"/)?.[1] ?? ''; } catch { return ''; - } finally { - if (fd !== undefined) closeSync(fd); } } @@ -779,7 +776,7 @@ export function listClaudePwds(who: ChatIdentity): ClaudePwd[] { } if (files.length === 0) continue; - const cwd = firstCwd(join(groupDir, files[0]!)); + const cwd = firstCwd(who.osUser, join(groupDir, files[0]!)); if (!cwd) continue; let updatedAt = ''; for (const f of files) { @@ -815,7 +812,7 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] { const sessions: TranscriptSummary[] = []; for (const file of readdirSync(dir)) { if (!file.endsWith('.jsonl')) continue; - const summary = summarizeTranscript(join(dir, file), file.replace(/\.jsonl$/, '')); + const summary = summarizeTranscript(who.osUser, join(dir, file), file.replace(/\.jsonl$/, '')); if (summary) sessions.push(summary); } return applyLineage(sessions); @@ -880,7 +877,7 @@ export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: // group this session belongs to — no need to reverse the slug, which is lossy. let cwd: string | null = null; try { - for (const line of readFileSync(filePath, 'utf-8').split('\n')) { + for (const line of readTextAs(who.osUser, filePath).split('\n')) { if (!line.trim()) continue; const entry = JSON.parse(line) as { cwd?: string }; if (entry.cwd) { diff --git a/src/servers/read-as-user.ts b/src/servers/read-as-user.ts new file mode 100644 index 00000000..a8dd91fa --- /dev/null +++ b/src/servers/read-as-user.ts @@ -0,0 +1,109 @@ +import { appendFileSync, closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs'; +import { runAsArgv } from './os-user'; + +// Reading a file that belongs to a member. +// +// ── Why the ACL grant is not enough ── +// +// `confineUserTree` gives the service user a named ACL entry on every member home (`u::rwx`, +// plus `d:` defaults so anything created later inherits it). That is what made the file browser work on +// 2026-08-11, and it is genuinely in force — `getfacl` on a member's home shows the entry. +// +// It does not survive contact with a file created at mode 600, because POSIX derives the ACL **mask** from +// the group bits of the creation mode, and the mask clamps every named entry: +// +// user:officer:rwx #effective:--- +// mask::--- +// +// `claude` writes every transcript at exactly that mode (verified: `.claude` and `projects/` are 775, every +// `*.jsonl` is 600). So the platform could list a member's transcripts and read not one byte of them — and +// `summarizeTranscript` catches EACCES and returns null, so the sessions did not fail, they *vanished*. A +// member chatted normally and their conversation list was empty on every refresh. +// +// No ACL fixes this. The creation mode ANDs the mask down, so `d:` defaults cannot raise it, and widening +// the mode would have to go through `other` — which is every account on the box. The only readers a 600 file +// has are its owner and root. +// +// ── So read as the owner of the file ── +// +// Which is what the terminal and the agent already do, through the same `runAsArgv` helper. The platform is +// the owner's process and could equally read via `sudo cat`, but acting AS the member keeps one rule instead +// of two: a member's bytes are reached through the member's identity, and the kernel stays the arbiter. +// +// Deliberately synchronous. `Bun.spawnSync` is what lets this drop into `claude-sessions.ts` — 914 lines and +// 28 functions of synchronous parsing, reached from five modules — without turning the whole read path async +// for a subprocess that takes a millisecond. The alternative was an `await` ripple through every caller for +// no behavioural gain. +// +// Only file CONTENT needs this. `statSync` needs traverse on the parent, `readdirSync` needs read on it, and +// both are satisfied by the 775 directories; creating and deleting entries inside them works too, because a +// directory's mask is `rwx`. So the privileged surface is small on purpose — see the call sites, not this +// file, for what actually needed it. + +/** Whose identity to read as. `null` is this process's own uid — the owner, and the common case. */ +export type AsUser = string | null; + +/** `Bun.spawnSync` through `setpriv`, or a throw carrying enough to tell EACCES from ENOENT. */ +function runSync(osUser: string, command: string[]): string { + const result = Bun.spawnSync(runAsArgv(osUser, command), { stdout: 'pipe', stderr: 'pipe' }); + if (result.exitCode !== 0) { + const detail = new TextDecoder().decode(result.stderr).trim() || `exit ${result.exitCode}`; + throw new Error(`reading as ${osUser} failed: ${detail}`); + } + return new TextDecoder().decode(result.stdout); +} + +/** The whole file, as text. Throws on any failure, so existing `try`/`catch` around reads keeps working. */ +export function readTextAs(osUser: AsUser, path: string): string { + if (!osUser) return readFileSync(path, 'utf-8'); + return runSync(osUser, ['cat', '--', path]); +} + +/** The first `bytes` bytes. Used where a header is all that is wanted and transcripts run to megabytes. */ +export function readHeadAs(osUser: AsUser, path: string, bytes: number): string { + if (!osUser) return readRange(path, 0, bytes); + return runSync(osUser, ['head', '-c', String(bytes), '--', path]); +} + +/** The last `bytes` bytes. `truncated` reports whether anything was left off the front. */ +export function readTailAs(osUser: AsUser, path: string, bytes: number): { text: string; truncated: boolean } { + // `statSync` needs traverse on the parent directory, not read on the file, so it works unprivileged even + // when the content does not — which is also what keeps the mtime cache in `summarizeTranscript` honest. + const size = statSync(path).size; + const truncated = size > bytes; + const text = osUser + ? runSync(osUser, ['tail', '-c', String(bytes), '--', path]) + : readRange(path, Math.max(0, size - bytes), bytes); + return { text, truncated }; +} + +/** Append one line, as its owner. The rename path writes a `summary` entry into the member's transcript. */ +export function appendTextAs(osUser: AsUser, path: string, text: string): void { + if (!osUser) { + appendFileSync(path, text); + return; + } + // `tee -a` rather than a shell redirect: no shell means no quoting question about the path. + const result = Bun.spawnSync(runAsArgv(osUser, ['tee', '-a', '--', path]), { + stdin: new TextEncoder().encode(text), + stdout: 'ignore', + stderr: 'pipe', + }); + if (result.exitCode !== 0) { + const detail = new TextDecoder().decode(result.stderr).trim() || `exit ${result.exitCode}`; + throw new Error(`appending as ${osUser} failed: ${detail}`); + } +} + +/** Owner fast path for a byte window — the same positional read the callers used before this file existed. */ +function readRange(path: string, start: number, bytes: number): string { + let fd: number | undefined; + try { + fd = openSync(path, 'r'); + const buf = Buffer.alloc(bytes); + const n = readSync(fd, buf, 0, bytes, start); + return buf.toString('utf-8', 0, n); + } finally { + if (fd !== undefined) closeSync(fd); + } +} diff --git a/src/servers/user-home.ts b/src/servers/user-home.ts index 12277877..9eb5ca73 100644 --- a/src/servers/user-home.ts +++ b/src/servers/user-home.ts @@ -21,7 +21,21 @@ import { getHomeDir, getOwnerHomeDir } from './data-path'; // No Linux account means no confinement means no access, and the refusal names the fix. export type HomeResolution = - | { ok: true; home: string; isOwner: boolean } + | { + ok: true; + home: string; + isOwner: boolean; + /** + * The Linux account whose identity reaches this home, or `null` for the owner — who IS this process's + * uid, so there is nobody to become. + * + * Carried because resolving the home is not enough to READ inside it: a member's files are theirs and + * `claude` writes transcripts at mode 600, which clamps the platform's ACL entry to nothing. See + * `read-as-user.ts`. Reported here rather than looked up again at each call site so that "whose home" + * and "whose identity" cannot drift apart — they are one answer from one row. + */ + osUser: string | null; + } | { ok: false; reason: string; needsOsAccount: boolean }; /** @@ -45,7 +59,7 @@ export async function resolveHomeDir(userId: number): Promise { // The owner runs in their real login home — the whole point of HOME_DIR, and what makes platform // terminals share config and credentials with the shell they use outside Officer. if (user.role === 'Super Admin') { - return { ok: true, home: getOwnerHomeDir(user.email), isOwner: true }; + return { ok: true, home: getOwnerHomeDir(user.email), isOwner: true, osUser: null }; } if (!user.osUser) { @@ -59,5 +73,5 @@ export async function resolveHomeDir(userId: number): Promise { // `getHomeDir` and `osUserHome` are deliberately the same path: DATA_PATH//home is both the // managed home the platform provisions and the real passwd home of the Linux account. If those ever // diverge, a member's shell and their file browser would show different directories. - return { ok: true, home: getHomeDir(user.email), isOwner: false }; + return { ok: true, home: getHomeDir(user.email), isOwner: false, osUser: user.osUser }; }