Files
platform/src/servers/user-home.ts
T
pastilhasandClaude Opus 5 336e718463 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>
2026-08-14 15:58:36 +00:00

78 lines
3.9 KiB
TypeScript

import { getUserById } from 'officerdb';
import { getHomeDir, getOwnerHomeDir } from './data-path';
// Whose home does a session run in.
//
// This is the seam the whole per-user story turns on, and it replaces `getOwnerHomeDir(email)` at every
// point where a REQUEST decides which directory it may touch. That function takes an email and discards it
// whenever `HOME_DIR` is set — which is always, on a real install — so every caller resolved to the owner's
// login home regardless of who was asking. Harmless while the surfaces around it were owner-only. Not
// harmless the moment a member can open a file browser.
//
// ── Why a member with no Linux account is REFUSED, not defaulted ──
//
// The tempting fallback is "no `osUser`? use the managed home under DATA_PATH anyway." It would work, and
// it would be wrong in the one direction that matters: a member's files would sit in a directory the
// platform's own service user owns and every other part of the system can read, while the UI showed them a
// private-looking home. Worse, an error in the resolution order — an owner check that stopped matching, say
// — would land a member in the OWNER'S home with write access, silently.
//
// So the rule is: a non-owner reaches an execution surface only if the kernel is enforcing the boundary.
// No Linux account means no confinement means no access, and the refusal names the fix.
export type HomeResolution =
| {
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 };
/**
* The directory this account's file browser, terminal and (eventually) agents are confined to.
*
* Reads the row rather than trusting the token, for the same reason `authorize.ts` re-reads `role`: a claim
* minted at sign-in would keep working for thirty days after the account it describes changed. Enabling OS
* users, or provisioning one for an existing member, must take effect on the next request.
*/
export async function resolveHomeDir(userId: number): Promise<HomeResolution> {
let user;
try {
user = await getUserById(userId);
} catch {
// Same posture as every catch in authorize.ts: a database error denies. There is no safe guess about
// whose home to open.
return { ok: false, reason: 'could not resolve your account', needsOsAccount: false };
}
if (!user) return { ok: false, reason: 'account not found', needsOsAccount: false };
// 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, osUser: null };
}
if (!user.osUser) {
return {
ok: false,
reason: 'this account has no Linux user on this machine, so there is nothing to confine it to',
needsOsAccount: true,
};
}
// `getHomeDir` and `osUserHome` are deliberately the same path: DATA_PATH/<email>/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, osUser: user.osUser };
}