feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall

- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
+14 -19
View File
@@ -11,9 +11,10 @@
* disabled user's sessions stop working at once (the lookup joins `users`).
* - `authGuard` (mounted on `/api`) lets through `/health` and `/auth/*`, the
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
* its own bearer token), and everything else only with a valid login. An
* `analyst` may only read; writes under `ADMIN_WRITE_PREFIXES` — the host
* account's own configuration — are an `admin`'s.
* its own bearer token), and everything else only with a valid login.
* Writes under `ADMIN_WRITE_PREFIXES` — the host account's own
* configuration — are an `admin`'s; an `analyst` and a `developer` write
* only their own rows (`scope.js`).
* - Accounts are made from the host's shell (`agenthub users …`) or by an
* admin on the Users page (`server/routes/users.js`), where every change
* needs the admin's own password again (`confirmPassword`, throttled).
@@ -36,7 +37,7 @@ const scrypt = promisify(crypto.scrypt);
const COOKIE = "agenthub_sid";
const SESSION_DAYS = 30;
const ROLES = ["admin", "analyst", "member"];
const ROLES = ["admin", "analyst", "developer"];
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const MIN_PASSWORD = 8;
@@ -58,13 +59,15 @@ const ADMIN_WRITE_PREFIXES = [
"/reports",
"/pets",
"/users",
// Alert rules and budgets fire for every user's sessions.
"/alerts/rules",
];
/**
* API prefixes a member may not reach at all, reads included: the host's own
* API prefixes a developer may not reach at all, reads included: the host's own
* configuration and logs, and every view across all users (export, metrics,
* reports, webhook deliveries, remote machines, imports). Admin and analyst
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a member's own work needs.
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a developer's own work needs.
*/
const STAFF_ONLY_PREFIXES = [
"/cc-config",
@@ -131,7 +134,7 @@ function findUser(username) {
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
}
async function createUser(username, password, role = "member") {
async function createUser(username, password, role = "developer") {
const name = validateUsername(username);
validatePassword(password);
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
@@ -392,7 +395,7 @@ function authGuard(req, res, next) {
const under = (x) => underPrefix(p, x);
const isRead = req.method === "GET" || req.method === "HEAD";
if (
user.role === "member" &&
user.role === "developer" &&
STAFF_ONLY_PREFIXES.some(under) &&
!(isRead && MEMBER_READ_ALLOW.some(under))
) {
@@ -407,8 +410,7 @@ function authGuard(req, res, next) {
if (raw && !isLoopback(raw) && !isOwnHost(raw, req)) {
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
}
if (user.role === "analyst")
return deny(res, 403, "EREADONLY", "an analyst account is read-only");
// An analyst writes like a developer: only their own rows (scope.js).
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
return deny(res, 403, "EADMIN", "only an admin may change this");
}
@@ -432,15 +434,8 @@ function isWebSocketLoggedIn(req) {
}
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
}
const user = userFromRequest(req);
if (
user &&
user.role === "analyst" &&
/^\/ws-pty\//.test(new URL(req.url, "http://x").pathname)
) {
return false; // a terminal is a write
}
return Boolean(user);
// Which terminals a login may open is the run check's (websocket.js).
return Boolean(userFromRequest(req));
}
module.exports = {