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