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:
+30
-9
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user