chore: v2.8.15 — Oh My Pi login sharing, omp --allow-home, Markdown Preview default, first-login password

- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as
  the host on loopback; a member's omp pane reaches it as a scoped client keyed
  by its pane token (clients file = live omp panes only), runs on
  --model agenthub/<model>, never holds a credential.
- omp catalog: --allow-home, so a workspace at the account's home keeps its
  sessions there (they were recorded under a temp dir and dropped).
- Settings → Appearance → Open Markdown as: Preview (default) or Raw, for
  Files/Changes, the config viewer and a chat's file panel.
- First-login password change and password rules, avatar URLs, Artifacts
  "Mine" includes installed shared skills/plugins, a member's own hook pages.
This commit is contained in:
2026-10-22 10:04:30 +07:00
parent 8a6dc6a29e
commit 750d74a2af
66 changed files with 3021 additions and 696 deletions
+127 -17
View File
@@ -22,6 +22,15 @@
* (`validateProfile`), which its owner edits in Settings → Account without
* that step-up; changing their OWN password there needs the old one and
* keeps the login it was changed from (`setPassword`'s `keepToken`).
* - Two password bars: an admin may GIVE any non-empty password (a create or a
* reset — `validatePassword`), and such an account is flagged
* `must_change_password`; a password its OWNER picks must pass every rule of
* `passwordProblems` (`validateOwnPassword`). While the flag is set, a
* cookie login reaches only `/auth/*`, the `/me` reads and
* `POST /me/password` (EPWCHANGE) and opens no WebSocket; a pane token is
* not held back — its agents were started before the reset.
* - Neither the dashboard nor the CLI can leave the install with no enabled
* admin (`assertKeepsAdmin`, ELASTADMIN).
*
* This is login, not isolation: every member still sees every workspace and
* session, and every pane runs as the one host account (phases S2/S3 of the
@@ -134,6 +143,39 @@ function validatePassword(password) {
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
}
/** Shortest password an account may pick for itself. */
const PASSWORD_MIN_LENGTH = 8;
/**
* The rules a password picked by its OWNER fails, as ids (empty = it passes):
* `length`, `lower`, `upper`, `digit`, `symbol` and `username` (it contains
* the username). The client's checklist (client/src/lib/passwordRules.ts)
* mirrors these; this is the one that decides.
*/
function passwordProblems(password, username) {
const p = String(password ?? "");
const out = [];
if ([...p].length < PASSWORD_MIN_LENGTH) out.push("length");
if (!/\p{Ll}/u.test(p)) out.push("lower");
if (!/\p{Lu}/u.test(p)) out.push("upper");
if (!/\p{N}/u.test(p)) out.push("digit");
if (!/[^\p{L}\p{N}]/u.test(p)) out.push("symbol");
const name = String(username || "").toLowerCase();
if (name && p.toLowerCase().includes(name)) out.push("username");
return out;
}
/** A password the account picks for itself: every rule (EWEAKPASSWORD). */
function validateOwnPassword(password, username) {
validatePassword(password);
const failed = passwordProblems(password, username);
if (failed.length) {
throw Object.assign(err("EWEAKPASSWORD", `password too weak: ${failed.join(", ")}`), {
rules: failed,
});
}
}
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
const publicUser = (u) =>
@@ -147,6 +189,8 @@ const publicUser = (u) =>
// When the avatar was last set (null = none): the client's cache-buster.
avatarAt: u.avatar_at ?? null,
createdAt: u.created_at ?? null,
// An admin gave this password: the account must pick its own first.
mustChangePassword: Boolean(u.must_change_password),
};
const MAX_DISPLAY_NAME = 100;
@@ -199,7 +243,8 @@ function findUser(username) {
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
}
async function createUser(username, password, role = "developer") {
/** `mustChange`: an admin gave the password — the user picks their own at first login. */
async function createUser(username, password, role = "developer", { mustChange = false } = {}) {
const name = validateUsername(username);
const linux = require("./linux-accounts");
linux.assertLinuxName(name);
@@ -210,8 +255,8 @@ async function createUser(username, password, role = "developer") {
// S3: the Linux account first — a user who cannot get one is not created.
linux.provision("create", name);
const { lastInsertRowid } = sql(
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
).run(name, role, hash);
"INSERT INTO users (username, role, password_hash, must_change_password) VALUES (?, ?, ?, ?)"
).run(name, role, hash, mustChange ? 1 : 0);
// S3, opt-in: the admin's plugin baseline, installed AS the new member in
// the background (each marketplace is a clone) — the user exists already.
require("./member-claude")
@@ -227,14 +272,29 @@ function requireUser(username) {
}
/** New password; every login of that user ends — but `keepToken`'s, when
* the user changed their own password from that login. */
async function setPassword(username, password, { keepToken } = {}) {
* the user changed their own password from that login. `mustChange`: an admin
* gave it, so the user must pick their own next; without it the flag clears. */
async function setPassword(username, password, { keepToken, mustChange = false } = {}) {
const u = requireUser(username);
validatePassword(password);
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
sql("UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?").run(
await hashPassword(password),
mustChange ? 1 : 0,
u.id
);
endSessions(u.id, keepToken);
}
/** EWEAKPASSWORD (`reused`) when `password` is the account's current one. */
async function assertNotCurrent(userId, password) {
const row = sql("SELECT password_hash FROM users WHERE id = ?").get(userId);
if (row && (await verifyPassword(String(password ?? ""), row.password_hash))) {
throw Object.assign(err("EWEAKPASSWORD", "the new password is the current one"), {
rules: ["reused"],
});
}
}
/** End every login of `userId` but `exceptToken`'s; returns how many ended. */
function endSessions(userId, exceptToken) {
if (exceptToken) {
@@ -246,18 +306,52 @@ function endSessions(userId, exceptToken) {
return sql("DELETE FROM auth_sessions WHERE user_id = ?").run(userId).changes;
}
/** `{ logins, lastLoginAt }`: the user's unexpired logins and the newest one's start. */
/** `{ logins, lastLoginAt }`: the user's unexpired logins and their last login
* ever (`users.last_login_at`; the newest login's start for one made before it). */
function loginStats(userId) {
const r = sql(
`SELECT COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
WHERE user_id = ? AND expires_at > ?`
).get(userId, new Date().toISOString());
return { logins: r.n, lastLoginAt: r.last || null };
const at = sql("SELECT last_login_at FROM users WHERE id = ?").get(userId)?.last_login_at;
return { logins: r.n, lastLoginAt: at || r.last || null };
}
/** `loginStats` for every account in two queries: Map(user id → stats). */
function allLoginStats() {
const now = new Date().toISOString();
const live = new Map(
sql(
`SELECT user_id, COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
WHERE expires_at > ? GROUP BY user_id`
)
.all(now)
.map((r) => [r.user_id, r])
);
const out = new Map();
for (const u of sql("SELECT id, last_login_at FROM users").all()) {
const r = live.get(u.id);
out.set(u.id, { logins: r?.n || 0, lastLoginAt: u.last_login_at || r?.last || null });
}
return out;
}
/**
* ELASTADMIN when `u` is the only enabled admin and the change would leave it
* no longer one — the CLI included, which has no "yourself" to refuse.
*/
function assertKeepsAdmin(u, what) {
if (u.role !== "admin" || u.disabled) return;
const others = sql(
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
).get(u.id).n;
if (!others) throw err("ELASTADMIN", `the last admin cannot be ${what}`);
}
function setRole(username, role) {
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
const u = requireUser(username);
if (role !== "admin") assertKeepsAdmin(u, "demoted");
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
if (role !== "admin") stopHostPanes(u.id);
}
@@ -287,6 +381,7 @@ function stopHostPanes(userId) {
* `deleteUser` below is the one that removes an account for good. */
function setDisabled(username, disabled) {
const u = requireUser(username);
if (disabled) assertKeepsAdmin(u, "disabled");
const linux = require("./linux-accounts");
// S3: lock (and end the processes of) the Linux account too — when there is
// one: a user made before S3, or whose name fits no Linux login, has none,
@@ -316,12 +411,7 @@ function setDisabled(username, disabled) {
*/
function deleteUser(username) {
const u = requireUser(username);
if (u.role === "admin") {
const others = sql(
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
).get(u.id).n;
if (!others) throw err("ELASTADMIN", "the last admin cannot be deleted");
}
assertKeepsAdmin(u, "deleted");
const lanes = sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(u.id);
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
const pty = require("./pty-run");
@@ -514,6 +604,7 @@ async function login(req, username, password) {
if (!u || !ok || u.disabled) throw err("EBADLOGIN", "wrong username or password");
failures.delete(ipKey);
failures.delete(userKey);
sql("UPDATE users SET last_login_at = ? WHERE id = ?").run(new Date().toISOString(), u.id);
return { user: publicUser(u), token: startSession(u.id) };
}
@@ -598,6 +689,12 @@ const underPrefix = (p, x) => {
const deny = (res, status, code, message) => res.status(status).json({ error: { code, message } });
/** What a login flagged `must_change_password` may still call. */
function mayBeforePasswordChange(method, lp) {
if (method === "GET" || method === "HEAD") return lp === "/me" || lp.startsWith("/me/");
return method === "POST" && lp === "/me/password";
}
/** Express middleware for `/api`; a no-op in local mode. */
function authGuard(req, res, next) {
if (!isServerMode()) return next();
@@ -610,9 +707,15 @@ function authGuard(req, res, next) {
? next()
: deny(res, 403, "ELOCALONLY", "hooks are accepted from this machine only");
}
const user = userFromRequest(req) || userFromPaneToken(req);
const cookieUser = userFromRequest(req);
const user = cookieUser || userFromPaneToken(req);
if (!user) return deny(res, 401, "EAUTH", "log in first");
req.user = user;
// A password an admin gave: the login may only read its own account and
// pick its own password until it has.
if (cookieUser?.mustChangePassword && !mayBeforePasswordChange(req.method, lp)) {
return deny(res, 403, "EPWCHANGE", "choose your own password first");
}
const under = (x) => underPrefix(p, x);
const isRead = req.method === "GET" || req.method === "HEAD";
if (
@@ -655,8 +758,10 @@ function isWebSocketLoggedIn(req) {
}
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
}
// Which terminals a login may open is the run check's (websocket.js).
return Boolean(userFromRequest(req));
// Which terminals a login may open is the run check's (websocket.js). None
// until a password an admin gave is replaced.
const user = userFromRequest(req);
return Boolean(user && !user.mustChangePassword);
}
module.exports = {
@@ -684,8 +789,13 @@ module.exports = {
validateProfile,
setProfile,
validatePassword,
validateOwnPassword,
passwordProblems,
assertNotCurrent,
PASSWORD_MIN_LENGTH,
endSessions,
loginStats,
allLoginStats,
hasAdmin,
userFromRequest,
login,