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
+3 -4
View File
@@ -112,11 +112,10 @@ router.get("/9router/update", async (req, res) => {
});
// POST /api/ai-proxies/9router/update — start the job; 202 with it. Poll the
// GET above for its steps. Admin only (also in auth.js ADMIN_WRITE_PREFIXES).
router.post("/9router/update", sameOriginGuard, async (req, res) => {
// GET above for its steps. Any signed-in user (auth.js MEMBER_WRITE_EXCEPTIONS):
// the job is fixed, nothing from the request reaches it.
router.post("/9router/update", sameOriginGuard, async (_req, res) => {
try {
if (req.user && req.user.role !== "admin")
throw Object.assign(new Error("only an admin may update 9router"), { code: "EADMIN" });
res.status(202).json(await nineRouterUpdate.start());
} catch (e) {
return fail(res, e);
+54
View File
@@ -30,6 +30,12 @@
* (PNG/JPEG/GIF/WebP, at most 1 MB), `POST /avatar/url` stores an https link
* instead, which `GET /avatar` redirects to (server/lib/avatar-url.js) and
* DELETE removes it — all writes are account writes like the profile's. The sidebar shows it beside the name.
* `/pool-accounts` (server mode) is the account pool as this account may use
* it (server/lib/account-pins.js `myAccounts`): GET lists, per tool, the
* accounts its group allows — never a token — with the admin's pin and its
* own choice; PUT `{tool, account}` chooses one (`null` = automatic) — refused
* while an admin pins it (`ELOCKED`) or for an account it may not use
* (`ENOACCOUNT`). An account write like the profile's: cookie only, audited.
*
* S3 (one Linux user per account): an account's dark/light choice also becomes
* the theme in their own Claude Code settings, written as them.
@@ -188,6 +194,10 @@ const ACCOUNT_STATUS = {
ETOOBIG: 413,
EREAUTH: 403,
ETOOMANY: 429,
EBADTOOL: 400,
ENOUSER: 404,
ENOACCOUNT: 404,
ELOCKED: 409,
};
function accountView(userId) {
@@ -316,6 +326,50 @@ router.delete(
})
);
// ── The account pool: the user's own choice of account per tool ───────────
const pins = require("../lib/account-pins");
/** Every tool with an account this user may use: those accounts, the admin's pin, their choice. */
router.get("/pool-accounts", (req, res) => {
if (!auth.isServerMode()) return res.json({ tools: [] });
const user = req.user || auth.userFromRequest(req);
if (!user) return unauth(res);
try {
res.json({ tools: pins.myAccounts(user.id) });
} catch (e) {
res.status(500).json({ error: { code: e.code || "EPOOL", message: e.message } });
}
});
/**
* `{tool, account}` — run this user's new sessions of `tool` on `account`
* (`null`: the balancer picks). A Claude login goes into their home at once,
* like an admin's switch (`switched`); anything else at the next session.
*/
router.put(
"/pool-accounts",
sameOriginGuard,
accountWrite(async (_req, user, body) => {
const choice = pins.setChoice(user.id, body.tool, body.account ?? null);
let switched = null;
if (choice) {
try {
switched = await pins.applyPin(user.id, body.tool);
} catch (e) {
console.warn(`[me] switch ${user.username} → ${choice}: ${e.message}`);
switched = "next-session";
}
}
return {
out: { tool: body.tool, choice, switched },
action: choice
? `own ${body.tool} pool account ${choice}`
: `own ${body.tool} pool account: automatic`,
};
})
);
// An avatar past its limit reaches here as express.raw's 413.
router.use((e, _req, res, next) => {
if (e && e.type === "entity.too.large")
+16 -2
View File
@@ -35,6 +35,20 @@ const {
const router = Router();
/**
* Whether `p` is a regular file, by `stat`. Not `fs.existsSync`: that is
* access(2), which checks the REAL uid with capabilities cleared, so it says
* "no" for a member's journal behind a 0700 folder (`~/.omp/agent`, S3) that
* the server itself reads through its CAP_DAC_READ_SEARCH.
*/
function isFile(p) {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
}
// JSONL entry types the transcript reader turns into renderable messages.
// `user`/`assistant` are the conversation. `custom-title` is the metadata line
// written by /rename, `claude -n`, and the picker's Ctrl+R — surfaced as an
@@ -864,7 +878,7 @@ router.get("/:id/transcripts", async (req, res) => {
? require("../lib/cursor-usage").transcriptPath(req.params.id)
: require("../lib/antigravity-usage").transcriptPath(req.params.id);
const list =
file && fs.existsSync(file)
file && isFile(file)
? [
{
id: "main",
@@ -1142,7 +1156,7 @@ router.get("/:id/transcript", async (req, res) => {
getSnapshotTranscriptPath(req.params.id);
}
if (!jsonlPath || !fs.existsSync(jsonlPath)) {
if (!jsonlPath || !isFile(jsonlPath)) {
return res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });
}
+3
View File
@@ -292,10 +292,13 @@ async function poolFor(uid) {
}
const ok = groups.allowed(uid, tool);
const pin = pins.pinFor(uid, tool);
const choice = pins.choiceFor(uid, tool);
const ranOn = used.filter((u) => u.tool === tool);
const wholeSession = tool !== "claude";
out[tool] = {
pin,
// The user's own pick (Settings → AI tools → Pool account), below a pin.
choice,
group: group ? { id: group.id, name: group.name } : null,
accounts: status
.filter((a) => ok(a.name) || ranOn.some((u) => u.account === a.name))