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