read a member's transcripts as the member
a provisioned member could chat normally and had no conversation list. every
refresh came back empty, so nothing could be resumed, and a new chat never
became a saved one.
nothing was wrong with the logic. the turn runs as them, writes its transcript
into their home, and the platform looks in exactly the right place — it just
cannot read what it finds.
confineUserTree grants the service user a named acl entry on every member home,
with d: defaults so anything created later inherits it. that entry is real and
getfacl shows it. it does not survive a file created at mode 600, because posix
derives the acl mask from the group bits of the creation mode:
user:officer:rwx #effective:---
mask::---
claude writes every transcript at exactly that mode — .claude and projects/ are
775, every *.jsonl is 600. so readdir and stat worked, every read raised eacces,
and summarizeTranscript catches eacces and returns null. the sessions did not
fail, they vanished.
no acl can fix this. the creation mode ands the mask down, so d: defaults cannot
raise it, and the only way up is through `other`, which is every account on the
box. a 600 file has two readers: its owner, and root.
so read as the owner of the file, through the same runAsArgv the terminal and
the agent already use. spawnSync keeps it synchronous, which is what lets it
drop into a 914-line synchronous parser reached from five modules instead of
rippling await through all of it.
the privileged surface turned out to be seven call sites, not the file: stat
needs traverse and readdir needs read, and the 775 directories give both. only
content needed identity.
also fixes a 500. parseClaudeTranscript read the file uncaught after an
existsSync that passes, so deep-linking /chat/<id> as a member threw rather than
404ing. it returns null now, like the list path always did.
verified against a throwaway linux account provisioned the same way a member is
— 700 home, named acl, transcript written as them at 600. before: 0 sessions and
loadClaudeSession null. after: the session, its title, its messages, and a
rename that leaves the file owned by the member at 600.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:<serviceUid>: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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user