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:
2026-10-13 17:10:29 +07:00
parent 9c573f1e3c
commit 0c2f49876d
74 changed files with 5347 additions and 392 deletions
+65
View File
@@ -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;