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
+30 -9
View File
@@ -39,7 +39,8 @@
* Share, unshare, install and uninstall are written to `audit_log`.
* - Editing a skill's name, description or instructions (`editSkill`) writes
* a NEW snapshot and swaps the row to it; the old snapshot is dropped, and
* copies already installed stay as they were (Reinstall picks it up).
* copies already installed stay as they were (uninstall and install again
* to take the new one).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -149,7 +150,8 @@ function view(row, req, owners = null) {
}
/**
* The rows this request may see. `scope`: `mine` (own rows; every row in
* The rows this request may see. `scope`: `mine` (own library rows plus the
* shared skills/plugins of others the caller has installed; every row in
* local mode), `hub` (shared rows, server mode), anything else = all visible.
* Each row also carries `installed` (`installedCheck`).
*/
@@ -158,8 +160,12 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
const where = [v.where];
const params = [...v.params];
if (scope === "mine" && req?.user) {
where.push("a.owner_id = ? AND a.in_library = 1");
params.push(req.user.id);
// Their own library, plus what others shared that they installed (kept
// below only when `installed` — a disk read, so it cannot be SQL).
where.push(
"((a.owner_id = ? AND a.in_library = 1) OR (a.shared = 1 AND a.owner_id IS NOT ? AND a.kind IN ('skill', 'plugin')))"
);
params.push(req.user.id, req.user.id);
} else if (scope === "hub") {
where.push("a.shared = 1");
}
@@ -193,7 +199,8 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
).all(...params);
const owners = new Map();
const installed = installedCheck(req);
return rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
const out = rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
}
// ── Small helpers ─────────────────────────────────────────────────────────
@@ -284,12 +291,25 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
);
}
/**
* Who a hook's page belongs to when no workspace owns its session (a member's
* `claude` started from their own terminal): the S3 member the hook's
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
* The host's account is shared by every admin, so it names nobody.
*/
function peerOwner(peer) {
if (peer?.kind !== "member" || !peer.name) return null;
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
return row ? row.id : null;
}
/**
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
* owned by the session's workspace owner. Returns the row or null.
* owned by the session's workspace owner, else by the member who sent the
* hook (`peerOwner`). Returns the row or null.
*/
function captureFromHook(sessionId, data) {
function captureFromHook(sessionId, data, peer = null) {
const input = data?.tool_input || {};
if (input.action && input.action !== "publish") return null;
const text = JSON.stringify(data?.tool_response ?? "");
@@ -297,7 +317,8 @@ function captureFromHook(sessionId, data) {
if (!m) return null;
const url = m[0].replace(/[\\"]+$/, "");
if (!PAGE_URL_RE.test(url)) return null;
const ownerId = sessionOwner(sessionId);
const laneId = sessionLane(sessionId);
const ownerId = laneId != null ? sessionOwner(sessionId) : peerOwner(peer);
const same = sql("SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?").get(
url,
ownerId
@@ -309,7 +330,7 @@ function captureFromHook(sessionId, data) {
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
description: cleanDescription(input.description),
owner_id: ownerId,
lane_id: sessionLane(sessionId),
lane_id: laneId,
session_id: String(sessionId),
source: file ? file.slice(0, 1000) : null,
url,
+40 -10
View File
@@ -8,11 +8,47 @@
* for a member (who can only read their own). An admin's account change on
* the Users page is written here too (`recordAccountChange`), and so is an
* admin's look into another user's home (`recordHomeRead`, File Explorer).
* Entries are kept `AGENTHUB_AUDIT_DAYS` days (default 180; `0` keeps them
* all): older ones are deleted as new ones are written, at most once an hour.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const sql = (q) => require("../db").db.prepare(q);
const DEFAULT_KEEP_DAYS = 180;
const PRUNE_EVERY_MS = 60 * 60 * 1000;
let lastPrune = 0;
/** Days of audit history kept; 0 = forever. */
function keepDays() {
const v = process.env.AGENTHUB_AUDIT_DAYS;
if (v === undefined || v === "") return DEFAULT_KEEP_DAYS;
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_KEEP_DAYS;
}
/** Deletes entries older than `keepDays()`; returns how many. `force` skips the hourly gate. */
function pruneAudit({ force = false } = {}) {
const days = keepDays();
if (!days || (!force && Date.now() - lastPrune < PRUNE_EVERY_MS)) return 0;
lastPrune = Date.now();
const cutoff = new Date(Date.now() - days * 864e5).toISOString();
try {
return sql("DELETE FROM audit_log WHERE at < ?").run(cutoff).changes;
} catch (err) {
console.warn("[audit] could not prune:", err.message);
return 0;
}
}
/** Writes one entry, then prunes when it is time. */
function insertEntry(userId, ownerId, sessionId, method, path) {
sql(
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
).run(userId, ownerId, sessionId, method, path);
pruneAudit();
}
// Paths that name one session's content.
const SESSION_PATHS = [
/^\/sessions\/(?!facets\/?$)([^/]+)/i,
@@ -35,9 +71,7 @@ function auditGuard(req, res, next) {
if (owner === u.id) continue;
const ownerRole = sql("SELECT role FROM users WHERE id = ?").get(owner)?.role;
if (ownerRole !== "developer") continue;
sql(
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
).run(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
insertEntry(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
}
} catch (err) {
console.warn("[audit] could not record:", err.message);
@@ -64,20 +98,16 @@ function listAudit(limit = 100) {
/** An admin's account change from the Users page: who, on whom, what (never
* a password). `owner_id` is the account changed; `path` carries the action. */
function recordAccountChange(actorId, targetId, method, action) {
sql(
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
).run(actorId, targetId, method, String(action).slice(0, 500));
insertEntry(actorId, targetId, null, method, String(action).slice(0, 500));
}
/** An admin opening another user's home in the File Explorer (read-only). */
function recordHomeRead(actorId, ownerId, method, what) {
try {
sql(
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
).run(actorId, ownerId, method, String(what).slice(0, 500));
insertEntry(actorId, ownerId, null, method, String(what).slice(0, 500));
} catch (err) {
console.warn("[audit] could not record:", err.message);
}
}
module.exports = { auditGuard, listAudit, recordAccountChange, recordHomeRead };
module.exports = { auditGuard, listAudit, recordAccountChange, recordHomeRead, pruneAudit };
+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,
+174
View File
@@ -0,0 +1,174 @@
/**
* @file avatar-url.js
* @description Fetches an avatar picture from a URL the account typed
* (Settings → Account, `POST /api/me/avatar/url`), so the server — not the
* browser, which most image hosts' CORS would block — downloads it. A URL the
* user names makes the SERVER connect somewhere, so it is held to: `https://`
* with no userinfo, a host whose EVERY resolved address is public (no
* loopback, private, link-local, CGNAT, multicast or reserved range — the
* dashboard's own network is never reachable this way), the connection pinned
* to the vetted address (a second DNS answer cannot rebind it), at most
* `MAX_REDIRECTS` redirects each re-checked the same way, a timeout, and at
* most `MAX_AVATAR_BYTES` read. No cookie or credential is ever sent. The
* bytes then go through `setAvatar`'s own magic-byte check like an upload.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const dns = require("node:dns");
const https = require("node:https");
const net = require("node:net");
const { MAX_AVATAR_BYTES } = require("./avatars");
const MAX_REDIRECTS = 3;
const TIMEOUT_MS = 10_000;
const fail = (code, message) => Object.assign(new Error(message), { code });
const blocked = new net.BlockList();
for (const [addr, bits] of [
["0.0.0.0", 8],
["10.0.0.0", 8],
["100.64.0.0", 10],
["127.0.0.0", 8],
["169.254.0.0", 16],
["172.16.0.0", 12],
["192.0.0.0", 24],
["192.0.2.0", 24],
["192.88.99.0", 24],
["192.168.0.0", 16],
["198.18.0.0", 15],
["198.51.100.0", 24],
["203.0.113.0", 24],
["224.0.0.0", 4],
["240.0.0.0", 4],
]) {
blocked.addSubnet(addr, bits, "ipv4");
}
for (const [addr, bits] of [
["::", 128],
["::1", 128],
["64:ff9b::", 96],
["64:ff9b:1::", 48],
["100::", 64],
["2001::", 23],
["2001:db8::", 32],
["2002::", 16],
["fc00::", 7],
["fe80::", 10],
["ff00::", 8],
]) {
blocked.addSubnet(addr, bits, "ipv6");
}
/** True only for a globally routable unicast address. */
function isPublicAddress(address) {
const family = net.isIP(address);
if (family === 4) return !blocked.check(address, "ipv4");
if (family !== 6) return false;
const lower = address.toLowerCase();
// An IPv4-mapped/compatible address is judged as the IPv4 address it carries.
const mapped = /^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
if (mapped) return isPublicAddress(mapped[1]);
if (/^::(?:ffff:)?[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(lower)) return false;
// Only global unicast (2000::/3) is public.
if (!/^[23][0-9a-f]{0,3}:/.test(lower)) return false;
return !blocked.check(address, "ipv6");
}
/** The URL as a `URL`, or EBADURL: https only, no userinfo, a real host. */
function parseAvatarUrl(text) {
let url;
try {
url = new URL(String(text ?? "").trim());
} catch {
throw fail("EBADURL", "not a valid URL");
}
if (url.protocol !== "https:") throw fail("EBADURL", "only an https:// link");
if (url.username || url.password) throw fail("EBADURL", "a link with a login is refused");
if (!url.hostname) throw fail("EBADURL", "not a valid URL");
// Node never calls `lookup` for an IP-literal host, so it is checked here.
const literal = url.hostname.replace(/^\[|\]$/g, "");
if (net.isIP(literal) && !isPublicAddress(literal)) {
throw fail("EPRIVATEURL", "that address is not on the public internet");
}
return url;
}
/**
* `lookup` for `https.request`: resolves every address and refuses the host
* when ANY of them is not public, then hands over only vetted ones.
*/
function safeLookup(hostname, options, cb) {
if (typeof options === "function") [cb, options] = [options, {}];
const literal = hostname.replace(/^\[|\]$/g, "");
const done = (addrs) => {
if (!addrs.length || !addrs.every((a) => isPublicAddress(a.address))) {
return cb(fail("EPRIVATEURL", "that address is not on the public internet"));
}
if (options?.all) return cb(null, addrs);
return cb(null, addrs[0].address, addrs[0].family);
};
if (net.isIP(literal)) return done([{ address: literal, family: net.isIP(literal) }]);
dns.lookup(hostname, { all: true, verbatim: true }, (err, addrs) => {
if (err) return cb(fail("EFETCH", `could not resolve ${hostname}`));
done(addrs);
});
}
function getOnce(url) {
return new Promise((resolve, reject) => {
const req = https.get(
url,
{
lookup: safeLookup,
agent: false,
timeout: TIMEOUT_MS,
headers: { Accept: "image/png,image/jpeg,image/gif,image/webp", "User-Agent": "AgentHub" },
},
(res) => {
const status = res.statusCode || 0;
if (status >= 300 && status < 400 && res.headers.location) {
res.resume();
return resolve({ redirect: new URL(res.headers.location, url).href });
}
if (status !== 200) {
res.resume();
return reject(fail("EFETCH", `the server answered ${status}`));
}
if (Number(res.headers["content-length"]) > MAX_AVATAR_BYTES) {
res.destroy();
return reject(fail("ETOOBIG", "the image is too large"));
}
const chunks = [];
let size = 0;
res.on("data", (chunk) => {
size += chunk.length;
if (size > MAX_AVATAR_BYTES) {
res.destroy();
return reject(fail("ETOOBIG", "the image is too large"));
}
chunks.push(chunk);
});
res.on("end", () => resolve({ body: Buffer.concat(chunks) }));
res.on("error", (e) => reject(e.code ? e : fail("EFETCH", e.message)));
}
);
req.on("timeout", () => req.destroy(fail("EFETCH", "the server took too long")));
req.on("error", (e) =>
reject(e.code === "EPRIVATEURL" || e.code === "EFETCH" ? e : fail("EFETCH", e.message))
);
});
}
/** Downloads the picture at `text`; resolves to its bytes (unchecked — `setAvatar` sniffs them). */
async function fetchAvatarImage(text) {
let url = parseAvatarUrl(text);
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const out = await getOnce(url);
if (out.body) return out.body;
url = parseAvatarUrl(out.redirect);
}
throw fail("EFETCH", "too many redirects");
}
module.exports = { fetchAvatarImage, isPublicAddress, parseAvatarUrl };
+464
View File
@@ -0,0 +1,464 @@
/**
* @file omp-share.js
* @description Server mode with S3: the HOST account's Oh My Pi (omp) login,
* shared with every member's omp pane without any member ever holding it.
* An admin's switch (Settings → AI tools → Oh My Pi login sharing,
* `GET/PUT /api/ai-tools/omp-sharing`), off by default.
*
* While it is on, this server keeps two omp daemons of its own running, as the
* host, on loopback only:
* - `omp auth-broker serve` over the host's own `~/.omp` credential store —
* the vault. Its bearer token (`~/.omp/auth-broker.token`, 0600 in the
* host's home) reaches nothing but the gateway's env.
* - `omp auth-gateway serve --clients=<file>` — an API gateway that answers
* `/v1/messages`, `/v1/chat/completions`, `/v1/responses`, `/v1/models`
* with the broker's credentials put in upstream. Its own unscoped token
* stays in the host's home too.
* A member's omp pane reaches the gateway as a SCOPED client: the clients file
* lists the SHA-256 of each live omp pane's own pane token
* (`dashboard_runs.pane_token_hash`, the same hash `auth.js` keeps), and its
* `~/.omp/agent/models.yml` gets one `agenthub` provider whose `apiKey` is the
* env var NAME `AGENTHUB_PANE_TOKEN`. A scoped client can call models but
* never read a credential or the gateway's usage, and its token stops working
* within the gateway's 10 s re-read once its pane is gone (`syncClients`) — so
* an agent can spend through the login while its pane lives, never carry it
* away. omp's gateway serves only its built-in providers' models (a custom
* `models.yml` provider of the host, such as a 9Router, is not shared).
*
* Nothing here returns or logs a token. Local mode and a server without S3
* never start anything.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const net = require("node:net");
const { spawn } = require("node:child_process");
const PROVIDER = "agenthub";
const TOKEN_ENV = "AGENTHUB_PANE_TOKEN";
const SYNC_MS = 15_000;
/** A spawn's token counts before its run row exists (recordRun runs after). */
const PENDING_MS = 60_000;
const port = (name, fallback) => {
const n = parseInt(process.env[name] || "", 10);
return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback;
};
const brokerPort = () => port("AGENTHUB_OMP_BROKER_PORT", 4826);
const gatewayPort = () => port("AGENTHUB_OMP_GATEWAY_PORT", 4827);
const dir = () => path.join(require("./claude-home").getDataDir(), "omp-share");
const onFlag = () => path.join(dir(), "enabled");
const clientsFile = () => path.join(dir(), "clients.json");
const pidsFile = () => path.join(dir(), "pids.json");
const logFile = (name) => path.join(dir(), `${name}.log`);
/** The host's own omp config dir, where the broker keeps its token. */
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
function available() {
return require("./linux-accounts").s3Enabled();
}
const enabled = () => fs.existsSync(onFlag());
function ensureDir() {
fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
}
// ── the clients file ──────────────────────────────────────────────────────
const pending = new Map(); // run id → { hash, at }
let lastClients = null;
/** `{ <run id>: { tokenSha256 } }` for every live omp pane, or null when tmux could not say. */
function liveClients() {
const live = require("./tmux").liveSessions();
if (!live) return null;
const rows = require("../db")
.db.prepare(
`SELECT id, pane_token_hash FROM dashboard_runs
WHERE tool = 'omp' AND status IN ('running', 'spawning') AND pane_token_hash IS NOT NULL`
)
.all();
const out = {};
for (const r of rows) if (live.has(r.id)) out[r.id] = { tokenSha256: r.pane_token_hash };
const now = Date.now();
for (const [id, p] of pending) {
if (now - p.at > PENDING_MS) pending.delete(id);
else out[id] = { tokenSha256: p.hash };
}
return out;
}
function writeClients(clients) {
const text = `${JSON.stringify(clients, null, 2)}\n`;
if (text === lastClients) return;
ensureDir();
const tmp = `${clientsFile()}.${process.pid}.tmp`;
fs.writeFileSync(tmp, text, { mode: 0o600 });
fs.renameSync(tmp, clientsFile());
lastClients = text;
}
/** Rewrite the clients file from the live omp panes (a gone pane's token drops out). */
function syncClients() {
if (!enabled() || !available()) return;
try {
const clients = liveClients();
if (clients) writeClients(clients);
} catch (err) {
console.warn("omp share: clients sync failed:", err.message);
}
}
// ── a member's models.yml ─────────────────────────────────────────────────
function providerEntry() {
return {
baseUrl: `http://127.0.0.1:${gatewayPort()}`,
apiKey: TOKEN_ENV,
api: "anthropic-messages",
authHeader: true,
discovery: { type: "proxy" },
};
}
/**
* The text of `models.yml` with our provider set, or null when it is already
* there as is. Throws on a file that is not a YAML mapping — it is the
* member's, and is never overwritten with something it did not say.
*/
function mergedModels(text) {
const yaml = require("js-yaml");
const doc = text && text.trim() ? yaml.load(text) : {};
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
throw new Error("models.yml is not a YAML mapping");
}
if (
doc.providers != null &&
(typeof doc.providers !== "object" || Array.isArray(doc.providers))
) {
throw new Error("models.yml `providers` is not a mapping");
}
const want = providerEntry();
const have = doc.providers?.[PROVIDER];
if (have && JSON.stringify(have) === JSON.stringify(want)) return null;
doc.providers = { ...(doc.providers || {}), [PROVIDER]: want };
return yaml.dump(doc, { lineWidth: -1, noRefs: true });
}
/** Point a member's omp at the gateway: their `~/.omp/agent/models.yml`, written AS them. */
function writeMemberModels(acct) {
const runAs = require("./run-as");
const agentDir = path.join(acct.home, ".omp", "agent");
const file = path.join(agentDir, "models.yml");
let text = "";
try {
text = runAs.readFileSafe(file, "utf8");
} catch (err) {
if (err.code !== "ENOENT") throw err;
runAs.mkdirAs(acct, agentDir);
}
const next = mergedModels(text);
if (next !== null) runAs.writeAs(acct, file, "replace", next);
}
/** The host's own default omp model (`modelRoles.default` in `~/.omp/agent/config.yml`), or null. */
function hostDefaultModel() {
try {
const doc = require("js-yaml").load(
fs.readFileSync(path.join(ompDir(), "agent", "config.yml"), "utf8")
);
const m = doc?.modelRoles?.default;
return typeof m === "string" && m.includes("/") ? m : null;
} catch {
return null;
}
}
/**
* The `--model` a shared pane runs on: the requested model, or the host's
* default, as the `agenthub` provider's id (`agenthub/<provider>/<model>`).
* Without it omp falls back to the provider's FIRST listed model — a retired
* `claude-3-5-sonnet` on the gateway, which answers 404 and retries forever —
* or to a built-in provider the member has no login for.
*/
function shareModel(requested, hostDefault = hostDefaultModel()) {
const want = requested || hostDefault;
if (!want) return requested || null;
if (want.startsWith(`${PROVIDER}/`)) return want;
if (want.includes("/")) return `${PROVIDER}/${want}`;
const provider = hostDefault ? hostDefault.split("/")[0] : "anthropic";
return `${PROVIDER}/${provider}/${want}`;
}
/**
* Called by `spawnRun` for an omp pane: `{env, model(), place()}` — env to add,
* the `--model` to run on (`shareModel`) and the home to prepare — or
* null when sharing is off (the pane then runs on the member's own login).
* Only a MEMBER's pane — the host's omp already holds the login itself.
*/
function forSpawn(member, runId, tokenHash) {
if (!member || !tokenHash || !enabled() || !available()) return null;
return {
env: { OMP_SKIP_SETUP: "1" },
model: (requested) => shareModel(requested),
place() {
try {
writeMemberModels(member);
} catch (err) {
console.warn(`omp share: ${member.login}'s models.yml left as is:`, err.message);
}
pending.set(runId, { hash: tokenHash, at: Date.now() });
syncClients();
},
};
}
// ── the two daemons ───────────────────────────────────────────────────────
const procs = { broker: null, gateway: null };
const status = { broker: null, gateway: null }; // { pid, startedAt } | { error }
let timers = [];
let stopping = false;
function ompBinary() {
return require("./ai-tools").detect("omp").path;
}
function readPids() {
try {
return JSON.parse(fs.readFileSync(pidsFile(), "utf8")) || {};
} catch {
return {};
}
}
function writePids() {
ensureDir();
const pids = {};
for (const [k, p] of Object.entries(procs)) if (p?.pid) pids[k] = p.pid;
fs.writeFileSync(pidsFile(), JSON.stringify(pids), { mode: 0o600 });
}
/** A daemon an earlier server left behind (KillMode=process keeps children): ours by its argv only. */
function killStale() {
for (const [name, pid] of Object.entries(readPids())) {
if (!Number.isInteger(pid)) continue;
let argv = "";
try {
argv = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
} catch {
continue;
}
if (argv.includes(`auth-${name}\0serve`)) {
try {
process.kill(pid, "SIGTERM");
} catch {
/* gone already */
}
}
}
}
function portOpen(p) {
return new Promise((resolve) => {
const s = net.connect({ host: "127.0.0.1", port: p });
s.once("connect", () => (s.destroy(), resolve(true)));
s.once("error", () => resolve(false));
});
}
async function waitPort(p, ms = 15_000) {
for (const end = Date.now() + ms; Date.now() < end;) {
if (await portOpen(p)) return true;
await new Promise((r) => setTimeout(r, 250));
}
return false;
}
/** The env a daemon gets: the host's basics only, never AgentHub's own secrets. */
function daemonEnv(extra = {}) {
const keep = ["HOME", "USER", "LOGNAME", "PATH", "LANG", "LC_ALL", "TMPDIR"];
const env = {};
for (const k of keep) if (process.env[k]) env[k] = process.env[k];
return { ...env, OMP_SKIP_SETUP: "1", ...extra };
}
function launch(name, bin, args, env) {
ensureDir();
const out = fs.openSync(logFile(name), "a", 0o600);
const child = spawn(bin, args, { env, stdio: ["ignore", out, out], windowsHide: true });
fs.closeSync(out);
procs[name] = child;
status[name] = { pid: child.pid, startedAt: new Date().toISOString() };
child.on("error", (err) => (status[name] = { error: err.message }));
child.on("exit", (code, signal) => {
// One this module stopped (or replaced) itself is no crash.
if (procs[name] !== child) return;
procs[name] = null;
if (stopping || !enabled()) return;
status[name] = { error: `exited (${signal || code})` };
// The pair restarts together: the gateway holds the broker's token.
restartSoon();
});
writePids();
return child;
}
let restartTimer = null;
let backoff = 5_000;
function restartSoon() {
if (restartTimer) return;
restartTimer = setTimeout(() => {
restartTimer = null;
stopDaemons();
start().catch(() => {});
backoff = Math.min(backoff * 2, 5 * 60_000);
}, backoff);
if (restartTimer.unref) restartTimer.unref();
}
async function startDaemons() {
const bin = ompBinary();
if (!bin) {
status.broker = status.gateway = { error: "omp is not installed" };
return;
}
killStale();
// The gateway refuses to start without the clients file it names.
if (!fs.existsSync(clientsFile())) writeClients({});
const bport = brokerPort();
const gport = gatewayPort();
for (const p of [bport, gport]) {
if (await waitPortClosed(p)) continue;
status.broker = status.gateway = { error: `port ${p} is taken` };
return;
}
launch("broker", bin, ["auth-broker", "serve", `--bind=127.0.0.1:${bport}`], daemonEnv());
if (!(await waitPort(bport))) {
status.broker = { error: "the broker did not start listening" };
return restartSoon();
}
let token;
try {
token = fs.readFileSync(path.join(ompDir(), "auth-broker.token"), "utf8").trim();
} catch {
status.broker = { error: "the broker wrote no token" };
return restartSoon();
}
launch(
"gateway",
bin,
["auth-gateway", "serve", `--bind=127.0.0.1:${gport}`, `--clients=${clientsFile()}`],
daemonEnv({
OMP_AUTH_BROKER_URL: `http://127.0.0.1:${bport}`,
OMP_AUTH_BROKER_TOKEN: token,
})
);
if (await waitPort(gport)) backoff = 5_000;
else status.gateway = { error: "the gateway did not start listening" };
}
/** A port a previous daemon is still letting go of gets a few seconds. */
async function waitPortClosed(p, ms = 5_000) {
for (const end = Date.now() + ms; Date.now() < end;) {
if (!(await portOpen(p))) return true;
await new Promise((r) => setTimeout(r, 250));
}
return false;
}
function stopDaemons() {
for (const [name, child] of Object.entries(procs)) {
if (!child) continue;
procs[name] = null;
try {
child.kill("SIGTERM");
} catch {
/* gone already */
}
}
try {
fs.rmSync(pidsFile(), { force: true });
} catch {
/* nothing recorded */
}
}
/** Start the daemons and the clients sync — when sharing is on, under S3 only. */
async function start() {
stopping = false;
if (!available() || !enabled()) return;
if (!timers.length) {
const t = setInterval(syncClients, SYNC_MS);
if (t.unref) t.unref();
timers.push(t);
}
syncClients();
if (procs.broker || procs.gateway) return;
await startDaemons();
}
/** Stop everything this module started (the server's shutdown, or the switch turned off). */
function stop() {
stopping = true;
for (const t of timers) clearInterval(t);
timers = [];
if (restartTimer) clearTimeout(restartTimer);
restartTimer = null;
stopDaemons();
status.broker = status.gateway = null;
}
async function setEnabled(on) {
ensureDir();
if (on) {
fs.writeFileSync(onFlag(), "", { mode: 0o600 });
await start();
} else {
fs.rmSync(onFlag(), { force: true });
stop();
// No pane keeps a way in once sharing is off.
lastClients = null;
pending.clear();
if (fs.existsSync(clientsFile())) writeClients({});
}
return state();
}
/** What the settings card shows — never a token. */
function state() {
const live = (name) => (procs[name] ? status[name] : null);
return {
available: available(),
enabled: enabled(),
installed: !!ompBinary(),
broker: live("broker")
? { running: true }
: { running: false, error: status.broker?.error || null },
gateway: live("gateway")
? { running: true, port: gatewayPort() }
: { running: false, error: status.gateway?.error || null },
};
}
module.exports = {
available,
enabled,
setEnabled,
start,
stop,
state,
forSpawn,
syncClients,
// exported for the tests
mergedModels,
providerEntry,
shareModel,
liveClients,
PROVIDER,
TOKEN_ENV,
};
+19 -2
View File
@@ -302,7 +302,11 @@ function buildCodexArgv({
* file, and the prompt is one argv element — or, for `after-start`, nothing
* here: spawnRun pastes it into the pane once the CLI is up.
*/
function buildCatalogArgv(entry, bin, { model, resumeSessionId, resumeLast, bypass, initialPrompt }) {
function buildCatalogArgv(
entry,
bin,
{ model, resumeSessionId, resumeLast, bypass, initialPrompt }
) {
const argv = [bin, ...entry.args];
if (model && entry.model) argv.push(entry.model.flag, model);
if (resumeSessionId && entry.resumeById) argv.push(...entry.resumeById, resumeSessionId);
@@ -485,6 +489,16 @@ function spawnRun(args) {
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
: null;
const paneCred = require("./auth").newPaneToken();
// A member's omp on the host's shared omp login (omp-share.js): its
// models.yml points at the loopback gateway, keyed by this pane's token.
const ompShare =
tool === "omp"
? require("./omp-share").forSpawn(
require("./run-as").accountForLane(laneId),
runIdForLane(laneId, slot),
paneCred?.hash
)
: null;
if (forkSession === true) {
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
if (entry.launcher !== "claude") {
@@ -540,7 +554,8 @@ function spawnRun(args) {
systemPrompt,
systemPromptFile,
tool,
model,
// A shared omp login runs on the gateway's provider (omp-share.js).
model: ompShare ? ompShare.model(model) : model,
permissionMode,
effort,
sandbox,
@@ -654,6 +669,7 @@ function spawnRun(args) {
// this process only. Through tmux `-e`, never typed into the pane.
...(poolAccount ? poolAccount.env : {}),
...(codexPool ? codexPool.env : {}),
...(ompShare ? ompShare.env : {}),
// Server mode: the CLI, MCP server and statusline in this pane act as the
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
@@ -688,6 +704,7 @@ function spawnRun(args) {
const prepareHome = () => {
codexPool?.place();
poolAccount?.place();
ompShare?.place();
if (member && entry.launcher === "claude" && (poolAccount || proxyProfile)) {
require("./run-as").markClaudeOnboarded(member);
}