import { appendFileSync, closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync, } from 'node:fs'; import { basename, join } from 'node:path'; 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. // // ── That last paragraph used to say only CONTENT needed this. It was wrong ── // // It claimed `statSync` and `readdirSync` were satisfied by "the 775 directories". They are not, because // there are no 775 directories on this path: `claude` creates `~/.claude/projects/` and each project group // at mode **700**, and the same rule that clamps a 600 file clamps a 700 directory — // // $ getfacl .../.claude/projects // user:officer:rwx #effective:--- // mask::--- // // so the service user has neither `r` nor `x` on it. Measured, not reasoned: // // existsSync(projects) -> true (stat needs traverse on `.claude`, which IS permissive) // readdirSync(projects) -> EACCES // existsSync(projects/) -> false (no `x` on projects, so it cannot even be reached) // statSync() -> EACCES // // `existsSync` returning **false** rather than throwing is what made this invisible: every caller read it as // "no such session" and returned an empty list or a 404. One bug, three symptoms — an empty conversation // list, no title on a new chat, and a /chat/ deep link that never restored. The content fix landed // without it because content reads were already funnelled through this file; enumeration never was. // // So enumeration is here too, and as ONE call rather than a spawn per entry: `readdir` + `stat` per file // would be dozens of `sudo setpriv` forks per request, each writing a line to `/var/log/auth.log`. A single // `find` answers the whole tree — which paths exist AND their mtimes — in one fork. /** One transcript on disk. `slug` is the project-group directory; `id` the session uuid. */ export type TranscriptFile = { slug: string; id: string; mtimeMs: number }; /** * Every `*.jsonl` under `projectsDir`, with mtimes, as the owner of the files. * * Replaces `readdirSync` + `statSync`, both of which fail for a member. Returns `[]` for a tree that does * not exist or cannot be read — the callers all treat "no transcripts" and "cannot look" the same way, and * there is no useful third answer to give a list endpoint. * * `onlySlug` narrows to one project group; it bounds the owner's syscalls and the member's `find`, and the * result is identical either way. */ export function listTranscriptsAs(osUser: AsUser, projectsDir: string, onlySlug?: string): TranscriptFile[] { if (!osUser) return listTranscriptsAsSelf(projectsDir, onlySlug); // GNU `-printf` is safe here: the member path exists only where `sudo setpriv` does, which is Linux. An // owner on macOS takes the branch above. // Depth follows the root: transcripts are `projects//.jsonl`, so scanning the whole tree is two // levels down and scanning one group is one. Pinning both bounds keeps `find` off the rest of the home. const root = onlySlug ? join(projectsDir, onlySlug) : projectsDir; const depth = onlySlug ? '1' : '2'; let out: string; try { out = runSync(osUser, [ 'find', root, '-mindepth', depth, '-maxdepth', depth, '-name', '*.jsonl', '-printf', '%h\t%f\t%T@\n', ]); } catch { // A missing tree exits non-zero, which is the same nothing as an empty one. return []; } const files: TranscriptFile[] = []; for (const line of out.split('\n')) { if (!line) continue; const [dir, name, mtime] = line.split('\t'); if (!dir || !name || !mtime) continue; files.push({ slug: basename(dir), id: name.replace(/\.jsonl$/, ''), mtimeMs: Math.round(Number(mtime) * 1000) }); } return files; } /** The owner's own files — no fork, because this process already IS them. */ function listTranscriptsAsSelf(projectsDir: string, onlySlug?: string): TranscriptFile[] { const slugs = onlySlug ? [onlySlug] : safeReaddir(projectsDir); const files: TranscriptFile[] = []; for (const slug of slugs) { for (const name of safeReaddir(join(projectsDir, slug))) { if (!name.endsWith('.jsonl')) continue; try { files.push({ slug, id: name.replace(/\.jsonl$/, ''), mtimeMs: statSync(join(projectsDir, slug, name)).mtimeMs, }); } catch { // Raced with a delete, or not a regular file. Either way it is not a transcript we can offer. } } } return files; } const safeReaddir = (dir: string): string[] => { try { return readdirSync(dir); } catch { return []; } }; /** 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 first path under `root` matching `predicate`, as the owner of the tree. * * Locating a file needs `x` on every directory above it, which the service user does not have on a * member's tree — the header explains why the ACL cannot give it. `/tmp/claude-` is `drwx------`, * so a glob run as the platform's own uid does not fail: it silently matches NOTHING, which reads * downstream as "no such task". * * `predicate` is `find` arguments, so callers state their own depth bounds — an unbounded walk of `/tmp` * or a member's home is not something to leave to chance. * * Owner and member take the same `find`, deliberately. One set of matching semantics beats a glob for us * and a `find` for them, which is exactly the kind of split that makes a bug reproduce on one account and * not the other. */ export function findPathAs(osUser: AsUser, root: string, predicate: string[]): string | null { const command = ['find', root, ...predicate]; const result = Bun.spawnSync(osUser ? runAsArgv(osUser, command) : command, { stdout: 'pipe', stderr: 'ignore' }); // The EXIT CODE IS IGNORED, and that is the whole trick. `find` exits 1 when it could not descend into // something — and searching `/tmp` from any identity is guaranteed to hit another account's // `drwx------`. It still prints what it DID find, on stdout, before returning 1. Treating that as // failure threw away the match: verified here, where the file was located and then discarded. // // A genuinely missing root also exits 1, with nothing on stdout, so absence still reads as absence. const first = new TextDecoder() .decode(result.stdout) .split('\n') .find((line) => line.length > 0); return first ?? null; } /** * Resolve a symlink, as the account that can traverse to it. Null when it dangles or cannot be reached. * * `realpathSync` needs `x` on every directory in the path, which the service user does not have on a * member's `/tmp/claude-` — so it throws EACCES on a link that is perfectly intact. The one caller * treats a throw as "dangling symlink" and returns null, which for a member turned every background task * into "nothing written yet", one layer below the lookup that had just been fixed to find it. * * `realpath -e` rather than `readlink -f`: `-e` requires every component to exist, which is what makes a * genuinely dangling link report as one instead of resolving to a path that is not there. */ export function realpathAs(osUser: AsUser, path: string): string | null { if (!osUser) { try { return realpathSync(path); } catch { return null; } } const result = Bun.spawnSync(runAsArgv(osUser, ['realpath', '-e', '--', path]), { stdout: 'pipe', stderr: 'ignore', }); if (result.exitCode !== 0) return null; return new TextDecoder().decode(result.stdout).trim() || null; } /** 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 } { if (!osUser) { const size = statSync(path).size; return { text: readRange(path, Math.max(0, size - bytes), bytes), truncated: size > bytes }; } // `statSync` is EACCES on a member's transcript — the header explains why — so the size has to come back // from the same identity as the bytes. One fork for both: `wc -c` writes the size on the first line, then // `tail` writes the window. Splitting them would double the forks and could straddle an append. const out = runSync(osUser, ['sh', '-c', 'wc -c < "$1"; tail -c "$2" -- "$1"', '_', path, String(bytes)]); const firstBreak = out.indexOf('\n'); const size = Number(out.slice(0, firstBreak).trim()); return { text: out.slice(firstBreak + 1), truncated: Number.isFinite(size) && size > bytes }; } /** 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}`); } } /** * Delete a file as its owner. Returns false if it was not there. * * Needed for the same reason the listing is: removing an entry needs `w`+`x` on the DIRECTORY, and the * service user has neither on a member's `projects/`. Without this, deleting a member's conversation * silently removed nothing and still answered `{ ok: true }`. */ export function removeAs(osUser: AsUser, path: string): boolean { if (!osUser) { if (!existsSync(path)) return false; rmSync(path); return true; } // `rm -f` exits 0 on a missing file, so absence is reported by testing first — in the same fork. const out = runSync(osUser, [ 'sh', '-c', 'if test -e "$1"; then rm -f -- "$1" && printf 1; else printf 0; fi', '_', path, ]); return out.trim() === '1'; } /** 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); } }