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:
@@ -87,7 +87,7 @@ export function buildAgentPrompt(agent: AgentRecord, inputs: Record<string, unkn
|
||||
* which is fine: while a run is live you find it as the newest entry in the agent's project group.
|
||||
*/
|
||||
function titleRun(
|
||||
who: { email: string; home: string },
|
||||
who: { email: string; home: string; osUser: string | null },
|
||||
cwd: string,
|
||||
claudeSessionId: string,
|
||||
agentName: string,
|
||||
@@ -172,7 +172,10 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
|
||||
if (msg.claudeSessionId) {
|
||||
run.claudeSessionId = msg.claudeSessionId;
|
||||
titleRun(
|
||||
{ email: params.user.email, home: homeDir },
|
||||
// `osUser: null` tracks `homeDir` above: it is `getOwnerHomeDir`, which discards the email it is
|
||||
// given, so an agent run is always the owner's — its transcript is theirs and readable directly.
|
||||
// If agent runs ever reach members, this and line 134 have to move together.
|
||||
{ email: params.user.email, home: homeDir, osUser: null },
|
||||
cwd,
|
||||
msg.claudeSessionId,
|
||||
agent.name || agent.dirName,
|
||||
|
||||
@@ -43,7 +43,7 @@ import { readSttConfig } from '../server-settings/stt';
|
||||
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
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';
|
||||
|
||||
@@ -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<string, { mtimeMs: number; summary: TranscriptSummary }>();
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<HomeResolution> {
|
||||
// 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<HomeResolution> {
|
||||
// `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 };
|
||||
return { ok: true, home: getHomeDir(user.email), isOwner: false, osUser: user.osUser };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user