feat: choose your own pool account; skills with "token" in a doc name upload; System Health off Linux

- Skill snapshot/upload: a key file's name (.env, *.pem, id_rsa, .netrc,
  .credentials.json…) is always refused, but a word like token/secret/
  password/credential only in a data file's name — design-tokens.md or
  count_tokens.py is skill content, not a key.
- System Health: off Linux the History, CPU threads and Busiest processes
  cards (all read from /proc) are left out instead of shown empty; the CPU
  bar without a sample and Windows' always-zero Load row go too.
- Users pick their own pool account per tool (/api/me/pool-accounts,
  Settings → AI tools), within their groups, refused while pinned.
- Admin user detail, 9router update, session replay and Antigravity
  catalog changes from parallel sessions.
This commit is contained in:
2026-10-28 10:41:53 +07:00
parent 4999325210
commit 0f17efe3b4
39 changed files with 1306 additions and 230 deletions
+105 -3
View File
@@ -13,6 +13,14 @@
*
* `setAccountUsers` is the Users page's Edit of one account: who runs on it.
*
* A user may also CHOOSE an account of their own (`account_choices`, Settings
* → AI tools → Pool account, `/api/me/pool-accounts`): `preferredFor` — the
* balancers' question — answers the admin's pin first, then that choice. A
* choice is only ever among the accounts the user's group may use
* (pool-groups.js `allowed`), checked when it is made and again at every
* spawn, so an account later taken from the group falls back to the balancer.
* A pinned user cannot choose (`ELOCKED`): the admin's pin is the decision.
*
* Server mode only: in local mode there are no users and nothing is pinned.
* Removing the account drops its pins (`dropAccount`); deleting the user drops
* theirs (ON DELETE CASCADE, and `auth.deleteUser`).
@@ -45,6 +53,95 @@ function pinFor(ownerId, tool) {
}
}
/** The account `userId` chose for `tool` themselves, or null. */
function choiceFor(userId, tool) {
if (!require("./auth").isServerMode() || !Number.isInteger(userId)) return null;
try {
return (
db()
.prepare("SELECT account FROM account_choices WHERE user_id = ? AND tool = ?")
.get(userId, tool)?.account ?? null
);
} catch {
return null; // a spawn never fails over a choice
}
}
/**
* The account the balancer should prefer for `ownerId`'s new `tool` pane: the
* admin's pin, else the owner's own choice while their group still allows it.
*/
function preferredFor(ownerId, tool) {
const pin = pinFor(ownerId, tool);
if (pin) return pin;
const choice = choiceFor(ownerId, tool);
if (!choice) return null;
return require("./pool-groups").allowed(ownerId, tool)(choice) ? choice : null;
}
/**
* `userId`'s own choice of `account` for `tool`, or back to the balancer
* (`account` null). Refused while an admin pins them (`ELOCKED`), and for an
* account not in the pool or not their group's (`ENOACCOUNT`).
*/
function setChoice(userId, tool, account) {
validTool(tool);
if (!Number.isInteger(userId)) throw err("ENOUSER", `no user ${userId}`);
if (pinFor(userId, tool)) {
throw err("ELOCKED", `an admin has pinned your ${tool} sessions to one account`);
}
if (account == null || account === "") {
db().prepare("DELETE FROM account_choices WHERE user_id = ? AND tool = ?").run(userId, tool);
return null;
}
const name = String(account);
if (!poolOf(tool).hasAccount(name) || !require("./pool-groups").allowed(userId, tool)(name)) {
throw err("ENOACCOUNT", `no ${tool} account ${name} you may use`);
}
db()
.prepare(
`INSERT INTO account_choices (user_id, tool, account) VALUES (?, ?, ?)
ON CONFLICT(user_id, tool) DO UPDATE SET account = excluded.account,
chosen_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
)
.run(userId, tool, name);
return name;
}
/**
* What Settings → AI tools → Pool account shows `userId`: per tool with at
* least one account they may use, those accounts (never a token), the admin's
* pin and their own choice.
*/
function myAccounts(userId) {
const groups = require("./pool-groups");
const out = [];
for (const tool of TOOLS) {
let status = [];
try {
status = poolOf(tool).poolStatus();
} catch {
status = [];
}
const ok = groups.allowed(userId, tool);
const pin = pinFor(userId, tool);
const accounts = status
.filter((a) => ok(a.name))
.map((a) => ({
name: a.name,
kind: a.kind,
email: a.email ?? null,
expired: Boolean(a.expired),
quotaPercent: a.quotaPercent ?? null,
full: Boolean(a.full),
}));
if (accounts.length === 0) continue;
const choice = choiceFor(userId, tool);
out.push({ tool, pin, choice: choice && ok(choice) ? choice : null, accounts });
}
return out;
}
/** Every pin of `tool`, with its user's name: `[{userId, username, account, pinnedAt}]`. */
function listPins(tool) {
return db()
@@ -120,23 +217,24 @@ function setAccountUsers(tool, account, userIds) {
}
/**
* Make a pin take effect now where it can: a Claude login is written into
* Make a pin — or the user's own choice — take effect now where it can: a Claude login is written into
* the user's home at once (claude-pool `switchHome`), so their running panes
* move to it without a restart. Anything else goes in at the next spawn.
* Answers what happened (see `switchHome`).
*/
async function applyPin(userId, tool) {
const pin = pinFor(userId, tool);
const pin = preferredFor(userId, tool);
if (!pin || tool !== "claude") return "next-session";
const user = db().prepare("SELECT username FROM users WHERE id = ?").get(userId);
const member = user ? require("./run-as").accountForUser(user.username) : null;
return poolOf(tool).switchHome(member, pin);
}
/** A removed account takes its pins — and its place in every group — with it. */
/** A removed account takes its pins, choices — and its place in every group — with it. */
function dropAccount(tool, account) {
try {
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
db().prepare("DELETE FROM account_choices WHERE tool = ? AND account = ?").run(tool, account);
} catch {
/* no database (a bare CLI test): nothing pinned */
}
@@ -147,6 +245,10 @@ module.exports = {
TOOLS,
poolOf,
pinFor,
choiceFor,
preferredFor,
setChoice,
myAccounts,
listPins,
setPin,
setAccountUsers,
+12 -3
View File
@@ -76,9 +76,18 @@ const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
const SNAPSHOT_RE = /^[A-Za-z0-9_-]{32}$/;
// A skill may not carry a login or a key: refused, never copied.
const SECRET_NAME_RE =
/(^\.env(\..*)?$|credential|secret|password|token|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|\.netrc$)/i;
// A skill may not carry a login or a key: refused, never copied. A key file's
// name alone proves it; a word like "token" does not — `design-tokens.md` or
// `tokenizer.py` is skill content — so the word rule skips docs and source.
const SECRET_FILE_RE =
/(^\.env(\..*)?$|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|^\.?netrc$|^\.credentials\.json$|^\.git-credentials$)/i;
const SECRET_WORD_RE = /(credential|secret|password|token)/i;
const CONTENT_EXT_RE =
/\.(md|mdx|markdown|rst|html?|css|js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|kt|swift|c|h|cpp|hpp|cs|php|sh|bash|zsh|ps1|lua|sql|svg|png|jpe?g|gif|webp)$/i;
const SECRET_NAME_RE = {
test: (name) =>
SECRET_FILE_RE.test(name) || (SECRET_WORD_RE.test(name) && !CONTENT_EXT_RE.test(name)),
};
const root = () => path.join(getDataDir(), "artifacts");
const installsOffFlag = () => path.join(root(), "installs-off");
+25 -3
View File
@@ -13,7 +13,8 @@
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
* its own bearer token), and everything else only with a valid login.
* Writes under `ADMIN_WRITE_PREFIXES` — the host account's own
* configuration — are an `admin`'s; an `analyst` and a `developer` write
* configuration — are an `admin`'s (bar `MEMBER_WRITE_EXCEPTIONS`: Update
* all AI tools and Update 9router); an `analyst` and a `developer` write
* only their own rows (`scope.js`).
* - Accounts are made from the host's shell (`agenthub users …`) or by an
* admin on the Users page (`server/routes/users.js`), where every change
@@ -80,11 +81,27 @@ const ADMIN_WRITE_PREFIXES = [
"/accounts",
// Alert rules and budgets fire for every user's sessions.
"/alerts/rules",
// Stopping, updating and restarting the host's 9router (both mounts).
// Stopping and restarting the host's 9router (both mounts) — its Update is
// in MEMBER_WRITE_EXCEPTIONS.
"/ai-proxies/9router",
"/ai-routers/9router",
];
/**
* The writes under `ADMIN_WRITE_PREFIXES` every signed-in user may still make,
* matched exactly (method + lower-cased path, a trailing slash ignored): the
* two "bring the host's tools up to date" buttons. Each runs only what a
* release catalog file / the fixed 9router job names — nothing from the
* request — so letting a member press it cannot widen what it runs. Install,
* a single tool's update, enable/default and every other `/ai-tools` write
* stay an admin's.
*/
const MEMBER_WRITE_EXCEPTIONS = new Set([
"POST /ai-tools/update-all",
"POST /ai-proxies/9router/update",
"POST /ai-routers/9router/update",
]);
/**
* API prefixes a developer may not reach at all, reads included: the host's own
* configuration and logs, and every view across all users (export, metrics,
@@ -738,7 +755,11 @@ function authGuard(req, res, next) {
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
}
// An analyst writes like a developer: only their own rows (scope.js).
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
if (
user.role !== "admin" &&
ADMIN_WRITE_PREFIXES.some(under) &&
!MEMBER_WRITE_EXCEPTIONS.has(`${req.method} ${lp.replace(/\/+$/, "")}`)
) {
return deny(res, 403, "EADMIN", "only an admin may change this");
}
return next();
@@ -771,6 +792,7 @@ module.exports = {
COOKIE,
ROLES,
ADMIN_WRITE_PREFIXES,
MEMBER_WRITE_EXCEPTIONS,
STAFF_ONLY_PREFIXES,
apiPath,
newPaneToken,
+3 -3
View File
@@ -507,9 +507,9 @@ function pick(member, ownerId = null) {
// in their home stays: one .credentials.json per home.
const allowed = require("./pool-groups").allowed(ownerId, "claude");
usable = usable.filter((a) => allowed(a.name));
// A pinned token goes in by env and wins outright; a pinned login waits
// while another login holds this home.
const pin = require("./account-pins").pinFor(ownerId, "claude");
// A pinned (or the owner's own chosen) token goes in by env and wins
// outright; a pinned login waits while another login holds this home.
const pin = require("./account-pins").preferredFor(ownerId, "claude");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned && (pinned.kind === "token" || !live || live === pinned)) return pinned;
if (live) return live;
+3 -3
View File
@@ -362,9 +362,9 @@ function pick(member, ownerId = null) {
// in their home stays: one auth.json per home.
const allowed = require("./pool-groups").allowed(ownerId, "codex");
usable = usable.filter((a) => allowed(a.name));
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
// waits while another login holds this home.
const pin = require("./account-pins").pinFor(ownerId, "codex");
// A pinned (or the owner's own chosen) API key goes in by env and wins
// outright; a pinned ChatGPT login waits while another login holds this home.
const pin = require("./account-pins").preferredFor(ownerId, "codex");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned && (pinned.kind === "apikey" || !live || live === pinned)) return pinned;
if (live) return live;
+11 -1
View File
@@ -176,6 +176,16 @@ function readChunk(file, offset, size) {
return { text: buf.toString("utf8", 0, end), bytes: end, more: offset + end < size && end > 0 };
}
/** A regular file, by `stat`: `existsSync` is access(2), blind to the
* CAP_DAC_READ_SEARCH that reaches a member's 0700 CODEX_HOME (S3). */
function isFile(p) {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
}
/** Every total vector in a parent journal: the history a fork copied. Streamed. */
async function parentTotals(file) {
const keys = new Set();
@@ -498,7 +508,7 @@ function ingestCodexUsage({
if (walkedAll) return null;
const known = threadPath.get(id)?.path;
const root = path.join(home || getCodexHome(), "sessions") + path.sep;
if (known && known.startsWith(root) && fs.existsSync(known)) return known;
if (known && known.startsWith(root) && isFile(known)) return known;
walkedAll = true;
for (const f of allRolloutFiles(home)) {
const m = FILE_NAME.exec(path.basename(f.path));
+1 -1
View File
@@ -118,7 +118,7 @@ function pick(ownerId = null) {
const allowed = require("./pool-groups").allowed(ownerId, "cursor");
const usable = allAccounts().filter((a) => allowed(a.name));
if (usable.length === 0) return null;
const pin = require("./account-pins").pinFor(ownerId, "cursor");
const pin = require("./account-pins").preferredFor(ownerId, "cursor");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned) return pinned;
return usable