One layer below yesterday's fix, and it looked identical on screen: the job was
found, then declared to have written nothing.
`loadBackgroundTask` resolved the located path with `realpathSync`, as the
platform. Resolving a link needs `x` on every directory above it, and
`/tmp/claude-<uid>` is `drwx------` — so it threw EACCES on an INTACT link, and
the catch below it reported that as a dangling symlink and returned null. The
tray polls, gets `{kind:'pending'}`, and shows "Nothing written yet" forever.
The lookup fix could not have exposed this: it made the file findable, which is
precisely what let execution reach the line that then threw it away.
`realpath -e` rather than `readlink -f`, because `-e` requires every component to
exist — that keeps a genuinely dangling link reporting as one instead of
resolving to a path that is not there. Verified against symlink, plain file,
dangling link and missing path: `realpath -e` and `realpathSync` agree on all
four, so the owner path is unchanged.
Audited the rest of claude-sessions.ts for the same shape. Every remaining
mention of `statSync`/`readdirSync`/`Bun.Glob` in that file is a comment
explaining why it is NOT used.
Live coverage extended: the member resolves their own link, the platform gets
null. Skips without OFFICER_TEST_MEMBER.
tsgo clean, frontend builds, 865 pass / 7 fail unchanged, 7 skips.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
14 KiB
TypeScript
310 lines
14 KiB
TypeScript
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:<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.
|
|
//
|
|
// ── 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/<slug>) -> false (no `x` on projects, so it cannot even be reached)
|
|
// statSync(<transcript>) -> 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/<id> 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/<slug>/<id>.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-<uid>` 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-<uid>` — 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/<slug>`. 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);
|
|
}
|
|
}
|