feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP
- Users page: one Edit user form (display name, email, role, active, a new or generated password, workspaces given or taken back, sign out everywhere), validated as a whole before anything is written (PATCH /api/users/:id, GET /api/users/:id); users.display_name/email added migration-safe. - Settings → Account for every signed-in user: profile, own password (the current one required, this login kept), sign out other devices (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie only, never a pane token). - Display names wherever a person is named (sidebar, workspace owner groups, Users page and audit log); owner_display_name / reader_display additive. - Users page: admins add and remove Claude/Codex pool accounts. - Folder browser: an admin with their own Linux account opens in their home; the "Server folder" tick opens the host's (never remembered). - AI Proxy: "Config 9router" beside Add profile when 9router is installed, opened on the dashboard's own host (GET /api/ai-proxies/9router). - Terminal: a tmux selection reaches the clipboard over plain HTTP too (writeClipboard's textarea fallback, focus handed back). - Chat: a file the session touched outside the workspace previews (GET /api/sessions/:id/file). - install-system: `!requiretty` only when visudo accepts it (sudo-rs on Ubuntu 25.10+ rejects the setting). - openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -543,6 +543,71 @@ router.get("/:id/image", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// A text file the session's own Read, Write, Edit, MultiEdit or NotebookEdit
|
||||
// named (`?path=`, exact), for the Chat pane's Outputs/Files preview when it
|
||||
// lies outside the workspace (a report written to ~/Documents, issue #76).
|
||||
// The same authorization as `/image` — that event, never a pushed session,
|
||||
// server mode only with S3 and a member's session only inside their own home
|
||||
// on a file they own — plus: never a credential by name
|
||||
// (`isSecretConfigPath`, a `settings.local.json`), never a binary or a
|
||||
// non-regular file, at most READ_TEXT_MAX bytes (the rest is `truncated`).
|
||||
const READ_TEXT_MAX = 512 * 1024;
|
||||
const sessionTouchedFile = db.prepare(
|
||||
`SELECT 1 FROM events
|
||||
WHERE session_id = ? AND event_type = 'PostToolUse'
|
||||
AND tool_name IN ('Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit')
|
||||
AND (json_extract(data, '$.tool_input.file_path') = ?
|
||||
OR json_extract(data, '$.tool_input.notebook_path') = ?)
|
||||
LIMIT 1`
|
||||
);
|
||||
router.get("/:id/file", async (req, res) => {
|
||||
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
const session = stmts.getSession.get(req.params.id);
|
||||
if (!session) return fail(404, "NOT_FOUND", "Session not found");
|
||||
const s3 = require("../lib/linux-accounts").s3Enabled();
|
||||
if ((isServerMode() && !s3) || String(session.source || "").startsWith("push:")) {
|
||||
return fail(403, "EUNSUPPORTED", "not available for this session");
|
||||
}
|
||||
const file = typeof req.query.path === "string" ? req.query.path : "";
|
||||
if (!path.isAbsolute(file)) return fail(400, "EBADPATH", "not an absolute path");
|
||||
const { isSecretConfigPath } = require("../lib/cc-discovery");
|
||||
const secret = (p) =>
|
||||
isSecretConfigPath(p) || path.basename(p).toLowerCase() === "settings.local.json";
|
||||
if (secret(file)) return fail(403, "ESECRET", "a credential file is never served");
|
||||
const runAs = require("../lib/run-as");
|
||||
const member = s3 ? runAs.ownerForRead(session.cwd) : null;
|
||||
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
||||
return fail(404, "ENOTFOUND", "the session never touched that file");
|
||||
}
|
||||
if (!sessionTouchedFile.get(session.id, file, file)) {
|
||||
return fail(404, "ENOTFOUND", "the session never touched that file");
|
||||
}
|
||||
let fh;
|
||||
try {
|
||||
// By what it RESOLVES to as well: a link named `notes.md` to a key file.
|
||||
if (secret(await fs.promises.realpath(file))) {
|
||||
return fail(403, "ESECRET", "a credential file is never served");
|
||||
}
|
||||
// NONBLOCK: a FIFO must not hang the event loop.
|
||||
fh = await fs.promises.open(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
||||
const st = await fh.stat();
|
||||
const fileOwner = s3 ? runAs.ownerForRead(file) : null;
|
||||
if (!st.isFile() || (fileOwner && st.uid !== fileOwner.uid)) {
|
||||
return fail(404, "ENOTFOUND", "no such file");
|
||||
}
|
||||
const buf = Buffer.alloc(Math.min(st.size, READ_TEXT_MAX));
|
||||
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
||||
const head = buf.subarray(0, bytesRead);
|
||||
if (head.subarray(0, 8000).includes(0)) return fail(415, "EBINARY", "not a text file");
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json({ text: head.toString("utf8"), truncated: st.size > READ_TEXT_MAX });
|
||||
} catch {
|
||||
if (!res.headersSent) fail(404, "ENOTFOUND", "no such file");
|
||||
} finally {
|
||||
await fh?.close();
|
||||
}
|
||||
});
|
||||
|
||||
/** The `/:id/stats` body — also frozen into a session snapshot. */
|
||||
function sessionStats(sessionId) {
|
||||
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
||||
|
||||
Reference in New Issue
Block a user