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:
@@ -623,6 +623,18 @@ describe("skills from files or written", () => {
|
||||
["s/.env", "K=1"],
|
||||
]);
|
||||
assert.equal(secret.body.error.code, "ESECRET");
|
||||
const word = await upload("/api/artifacts/skill/upload", [
|
||||
["w/SKILL.md", "---\ndescription: W\n---\n"],
|
||||
["w/api-token.json", "{}"],
|
||||
]);
|
||||
assert.equal(word.body.error.code, "ESECRET");
|
||||
// The word alone in a doc or a script is skill content, not a key.
|
||||
const docs = await upload("/api/artifacts/skill/upload", [
|
||||
["tokens/SKILL.md", "---\ndescription: Design tokens\n---\n"],
|
||||
["tokens/design-tokens.md", "# tokens"],
|
||||
["tokens/scripts/count_tokens.py", "print(1)"],
|
||||
]);
|
||||
assert.equal(docs.status, 201, JSON.stringify(docs.body));
|
||||
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], {
|
||||
description: "d",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @file pool-account-choice.test.js
|
||||
* @description A user's own choice of pool account (account-pins.js
|
||||
* `setChoice`/`preferredFor`, `/api/me/pool-accounts`), fake accounts only:
|
||||
* the balancer runs the user's new panes on the chosen account; an admin's pin
|
||||
* wins and locks the choice (ELOCKED); only accounts the user's group allows
|
||||
* may be chosen (ENOACCOUNT), and one later taken from the group falls back
|
||||
* to the balancer; a removed account drops its choices; the routes list no
|
||||
* token, need a login cookie and refuse in local mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pool-choice-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const groups = require("../lib/pool-groups");
|
||||
const cursorPool = require("../lib/cursor-pool");
|
||||
const claudePool = require("../lib/claude-pool");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
|
||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||
let server;
|
||||
let BASE;
|
||||
let MIA;
|
||||
let LEO;
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Origin: BASE,
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
await auth.createUser("boss", "admin-pass-1", "admin");
|
||||
MIA = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
LEO = (await auth.createUser("leo", "member-pass-2", "developer")).id;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.prepare("DELETE FROM pool_groups").run();
|
||||
db.prepare("DELETE FROM account_pins").run();
|
||||
db.prepare("DELETE FROM account_choices").run();
|
||||
for (const dir of [cursorPool.poolDir(), claudePool.poolDir()]) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
for (const c of ["a", "b", "c"]) cursorPool.addAccount(c, { apiKey: KEY(c) });
|
||||
});
|
||||
|
||||
describe("pool account choice: the balancer", () => {
|
||||
it("runs the user's new panes on the account they chose, others unchanged", () => {
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "a");
|
||||
pins.setChoice(MIA, "cursor", "c");
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||
assert.equal(cursorPool.pickAccount(LEO).name, "a");
|
||||
claudePool.addAccount("one", TOKEN("o"));
|
||||
claudePool.addAccount("two", TOKEN("t"));
|
||||
pins.setChoice(MIA, "claude", "two");
|
||||
assert.equal(claudePool.pickAccount(null, MIA).name, "two");
|
||||
pins.setChoice(MIA, "cursor", null);
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "a", "automatic again");
|
||||
});
|
||||
|
||||
it("lets an admin's pin win, and refuses a choice while pinned", () => {
|
||||
pins.setChoice(MIA, "cursor", "b");
|
||||
pins.setPin(MIA, "cursor", "c");
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||
assert.throws(() => pins.setChoice(MIA, "cursor", "a"), { code: "ELOCKED" });
|
||||
pins.setPin(MIA, "cursor", null);
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "b", "the choice comes back");
|
||||
});
|
||||
|
||||
it("allows only the group's accounts, and falls back once one leaves it", () => {
|
||||
const g = groups.createGroup("A");
|
||||
groups.setGroupUsers(g.id, [MIA]);
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b", "c"]);
|
||||
assert.throws(() => pins.setChoice(MIA, "cursor", "a"), { code: "ENOACCOUNT" });
|
||||
assert.throws(() => pins.setChoice(MIA, "cursor", "zz"), { code: "ENOACCOUNT" });
|
||||
pins.setChoice(MIA, "cursor", "c");
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||
assert.equal(pins.preferredFor(MIA, "cursor"), null);
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "b");
|
||||
});
|
||||
|
||||
it("drops a removed account's choices", () => {
|
||||
pins.setChoice(MIA, "cursor", "b");
|
||||
cursorPool.removeAccount("b");
|
||||
assert.equal(pins.choiceFor(MIA, "cursor"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pool account choice: /api/me/pool-accounts", () => {
|
||||
it("lists only the tools and accounts the user may use, never a token", async () => {
|
||||
const g = groups.createGroup("A");
|
||||
groups.setGroupUsers(g.id, [MIA]);
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||
const cookie = await login("mia", "member-pass-1");
|
||||
const r = await call("GET", "/api/me/pool-accounts", { cookie });
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(
|
||||
r.body.tools.map((t) => t.tool),
|
||||
["cursor"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
r.body.tools[0].accounts.map((a) => a.name),
|
||||
["b"]
|
||||
);
|
||||
assert.ok(!JSON.stringify(r.body).includes(KEY("b")));
|
||||
});
|
||||
|
||||
it("chooses, goes back to automatic, refuses while pinned, and is audited", async () => {
|
||||
const cookie = await login("leo", "member-pass-2");
|
||||
let r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: "c" },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.choice, "c");
|
||||
assert.equal(r.body.switched, "next-session");
|
||||
assert.equal(cursorPool.pickAccount(LEO).name, "c");
|
||||
r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: "nope" },
|
||||
});
|
||||
assert.equal(r.status, 404);
|
||||
assert.equal(r.body.error.code, "ENOACCOUNT");
|
||||
r = await call("PUT", "/api/me/pool-accounts", { cookie, body: { tool: "x", account: "c" } });
|
||||
assert.equal(r.status, 400);
|
||||
pins.setPin(LEO, "cursor", "a");
|
||||
r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: "b" },
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ELOCKED");
|
||||
pins.setPin(LEO, "cursor", null);
|
||||
r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: null },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.choice, null);
|
||||
const audited = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path LIKE '%pool account%'")
|
||||
.get().n;
|
||||
assert.ok(audited >= 2);
|
||||
});
|
||||
|
||||
it("needs a login", async () => {
|
||||
const r = await call("PUT", "/api/me/pool-accounts", {
|
||||
body: { tool: "cursor", account: "a" },
|
||||
});
|
||||
assert.equal(r.status, 401);
|
||||
});
|
||||
});
|
||||
@@ -782,6 +782,25 @@ describe("pty-run", () => {
|
||||
})
|
||||
);
|
||||
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
|
||||
|
||||
// A named conversation: agy's `--conversation <id>`, not `--continue`.
|
||||
tmux.__reset();
|
||||
const named = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
laneId: 14,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
resumeSessionId: "70785283-bcc4-43a4-a1ea-02d844d5df5b",
|
||||
initialPrompt: "go on",
|
||||
})
|
||||
);
|
||||
assert.deepEqual(paneArgv(named), [
|
||||
at("agy"),
|
||||
"--conversation",
|
||||
"70785283-bcc4-43a4-a1ea-02d844d5df5b",
|
||||
"-i",
|
||||
"go on",
|
||||
]);
|
||||
});
|
||||
|
||||
// A catalog tool has no "resume THIS session" argv; starting it fresh under
|
||||
@@ -792,7 +811,7 @@ describe("pty-run", () => {
|
||||
pty.spawnRun({
|
||||
laneId: 15,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
tool: "septool",
|
||||
resumeSessionId: "abc12345",
|
||||
}),
|
||||
{ code: "EUNSUPPORTED" }
|
||||
|
||||
@@ -137,6 +137,47 @@ describe("server mode", () => {
|
||||
assert.equal(a.status, 404);
|
||||
});
|
||||
|
||||
it("lets any user press Update all AI tools and Update 9router, nothing else there", async () => {
|
||||
// The guard alone, called directly: a real request would start the job.
|
||||
const member = await loginAs("mia", "member-pass-1");
|
||||
const guard = (method, p) => {
|
||||
let passed = false;
|
||||
let status = null;
|
||||
const req = { method, path: p, headers: { cookie: member }, socket: {} };
|
||||
const res = {
|
||||
status(s) {
|
||||
status = s;
|
||||
return this;
|
||||
},
|
||||
json() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
auth.authGuard(req, res, () => {
|
||||
passed = true;
|
||||
});
|
||||
return passed ? "next" : status;
|
||||
};
|
||||
for (const p of [
|
||||
"/ai-tools/update-all",
|
||||
"/AI-Tools/update-all/",
|
||||
"/ai-proxies/9router/update",
|
||||
"/ai-routers/9router/update",
|
||||
]) {
|
||||
assert.equal(guard("POST", p), "next", p);
|
||||
}
|
||||
for (const [method, p] of [
|
||||
["POST", "/ai-tools/claude/update"],
|
||||
["POST", "/ai-tools/claude/install"],
|
||||
["PUT", "/ai-tools/claude"],
|
||||
["PUT", "/ai-tools/omp-sharing"],
|
||||
["DELETE", "/ai-tools/update-all"],
|
||||
["POST", "/ai-proxies/9router/update/x"],
|
||||
]) {
|
||||
assert.equal(guard(method, p), 403, `${method} ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes hooks from this machine only", async () => {
|
||||
const remote = await call("POST", "/api/hooks/event", {
|
||||
body: {},
|
||||
|
||||
@@ -13,7 +13,7 @@ id follows, as one more argv element — `pty-run.js` emits them in place of
|
||||
`resume` when a start carries `resumeSessionId`, and only for an id that starts
|
||||
with a letter or digit. A tool without it (and without a `launcher`) refuses a
|
||||
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
|
||||
Built-in: omp and Cursor (`--resume`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
|
||||
Built-in: omp and Cursor (`--resume`), Antigravity (`--conversation`: an agy session's id is its conversation id). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
|
||||
|
||||
## Install / update (A2)
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--conversation"
|
||||
],
|
||||
"bypass": [
|
||||
"--dangerously-skip-permissions"
|
||||
],
|
||||
|
||||
@@ -1972,6 +1972,18 @@ db.exec(`
|
||||
PRIMARY KEY (user_id, tool)
|
||||
);
|
||||
`);
|
||||
// A user's OWN choice of one pool account per tool (Settings → AI tools →
|
||||
// Pool account, account-pins.js `setChoice`): below an admin's pin, above the
|
||||
// balancer, and only among the accounts the user's group may use.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS account_choices (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tool TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
chosen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (user_id, tool)
|
||||
);
|
||||
`);
|
||||
// Account pool groups (server/lib/pool-groups.js): a team's members run on the
|
||||
// team's own pool accounts. One group per user; an account, named like a pin,
|
||||
// may serve several groups. Accounts no group lists are the shared pool.
|
||||
|
||||
+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
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
||||
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
||||
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
|
||||
* - GET/PUT /api/me/pool-accounts (Auth) — the account's own pick of a pool account per tool
|
||||
* - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others
|
||||
* (Auth) — Settings → Account: the account's own profile, password and logins
|
||||
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
||||
@@ -243,6 +244,103 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/pool-accounts": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "The account pool as this account may use it (server mode)",
|
||||
description:
|
||||
"Per tool with at least one account the account's pool group allows: those accounts (never a token), an admin's pin and the account's own choice. Local mode: {tools: []}.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tools",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["tools"],
|
||||
properties: {
|
||||
tools: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["tool", "pin", "choice", "accounts"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
pin: { type: "string", nullable: true },
|
||||
choice: { type: "string", nullable: true },
|
||||
accounts: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
kind: { type: "string" },
|
||||
email: { type: "string", nullable: true },
|
||||
expired: { type: "boolean" },
|
||||
quotaPercent: { type: "number", nullable: true },
|
||||
full: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: err("EAUTH"),
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Auth"],
|
||||
summary: "Run this account's new sessions of a tool on one pool account (null = automatic)",
|
||||
description:
|
||||
"Below an admin's pin, above the balancer. A Claude login goes into the account's home at once (`switched`); Codex and Cursor take it at the next session. Login cookie only, audited.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["tool"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
account: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Chosen",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tool: { type: "string" },
|
||||
choice: { type: "string", nullable: true },
|
||||
switched: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
enum: ["switched", "kept", "own-login", "expired", "next-session"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: err("EBADTOOL"),
|
||||
401: err("EAUTH"),
|
||||
403: err("ELOCALMODE"),
|
||||
404: err("ENOACCOUNT — not in the pool, or not one the account's group may use"),
|
||||
409: err("ELOCKED — an admin pinned this account's sessions of the tool"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/onboarded": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -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