fa27b299e7
An admin sets a default spend quota on the Users page and overrides it per
user (Edit user → Spend quota: default / no limit / their own), in dollars
per local day or per sliding 5 hours. Spend is the users page's own
hourlyTokenRows + calculateCost over the sessions a user owns.
Over the quota, until it opens again:
- spawnRun refuses every start/resume/home prompt with EQUOTA (429);
- a minute sweep writes each pane's AGENTHUB_QUOTA_FILE (host: data dir,
member: handoff dir via write-as) and hook-handler.js exits 2 on
UserPromptSubmit from that file alone (Claude Code, Codex with trusted
hooks) — fail-open, never asks the dashboard, never for Antigravity;
- a member's omp on the shared login loses its gateway key (clientsFor);
- panes no hook can stop (Antigravity, Cursor, Codex without trusted
hooks, omp on its own login) are ended.
GET /api/me/quota feeds a "Quota N%" chip in the status bar.
New: server/lib/user-quota.js, user_quotas table, PUT /api/users/quota-default,
PATCH /api/users/:id {quota}, docs in SERVER-MODE.md § Spend quotas.
344 lines
14 KiB
JavaScript
344 lines
14 KiB
JavaScript
/**
|
|
* @file user-quota.test.js
|
|
* @description Spend quotas per user (server/lib/user-quota.js): the default
|
|
* and a user's own setting, spend over a day and a sliding 5 h window, the
|
|
* admin routes (password again), `/api/me/quota`, the EQUOTA refusal of a new
|
|
* pane, the sweep's flag files and the panes it ends, the omp gateway keys it
|
|
* leaves out, and the hook handler refusing a prompt from the flag alone.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = 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 { spawnSync } = require("node:child_process");
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "user-quota-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
|
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
process.env.DASHBOARD_QUOTA_SWEEP_MS = "0";
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const { db } = require("../db");
|
|
const auth = require("../lib/auth");
|
|
const quota = require("../lib/user-quota");
|
|
const runs = require("../lib/pty-run");
|
|
|
|
let server;
|
|
let BASE;
|
|
const BOSS_PASS = "admin-pass-1";
|
|
const ids = {};
|
|
let laneId;
|
|
|
|
async function call(method, p, { cookie, body } = {}) {
|
|
const res = await fetch(BASE + p, {
|
|
method,
|
|
headers: { "Content-Type": "application/json", ...(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];
|
|
}
|
|
|
|
const HOUR = 3_600_000;
|
|
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
|
|
|
|
/** `tokens` output tokens of Sonnet 4.5 ($15 / M) booked to `sid` in the hour of `at`. */
|
|
function spend(sid, tokens, at = Date.now()) {
|
|
db.prepare(
|
|
`INSERT INTO token_usage (session_id, model, output_tokens)
|
|
VALUES (?, 'claude-sonnet-4-5-20250929', ?)
|
|
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
|
|
).run(sid, tokens);
|
|
db.prepare(
|
|
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
|
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
|
|
).run(sid, hourOf(at), tokens);
|
|
}
|
|
|
|
before(async () => {
|
|
process.env.AGENTHUB_MODE = "server";
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
|
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
|
ids.lou = (await auth.createUser("lou", "member-pass-2", "developer")).id;
|
|
laneId = Number(
|
|
db
|
|
.prepare(
|
|
"INSERT INTO lanes (cwd, title, tool, owner_id) VALUES ('/w/mia', 'Mia', 'claude', ?)"
|
|
)
|
|
.run(ids.mia).lastInsertRowid
|
|
);
|
|
db.prepare(
|
|
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sm', 'mia work', 'active', '/w/mia', ?, ?)"
|
|
).run(laneId, new Date().toISOString());
|
|
spend("sm", 1_000_000); // $15 now
|
|
});
|
|
|
|
after(() => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
server?.close();
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("settings", () => {
|
|
it("a user without a row of their own gets the default; unlimited and custom override it", () => {
|
|
assert.equal(quota.effective(ids.mia), null);
|
|
quota.setDefault({ limit_usd: 10, period: "day", tz_offset: -420 });
|
|
assert.deepEqual(quota.effective(ids.mia), {
|
|
source: "default",
|
|
limit_usd: 10,
|
|
period: "day",
|
|
tz_offset: -420,
|
|
});
|
|
quota.setUserQuota(ids.mia, { mode: "unlimited" });
|
|
assert.equal(quota.effective(ids.mia), null);
|
|
assert.deepEqual(quota.getOverride(ids.mia), { mode: "unlimited" });
|
|
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 50, period: "5h" });
|
|
assert.equal(quota.effective(ids.mia).source, "user");
|
|
assert.equal(quota.effective(ids.mia).limit_usd, 50);
|
|
quota.setUserQuota(ids.mia, { mode: "default" });
|
|
assert.equal(quota.effective(ids.mia).source, "default");
|
|
quota.setDefault(null);
|
|
assert.equal(quota.effective(ids.mia), null);
|
|
});
|
|
|
|
it("refuses a bad limit, period or mode", () => {
|
|
for (const bad of [
|
|
{ mode: "custom", limit_usd: 0, period: "day" },
|
|
{ mode: "custom", limit_usd: 5, period: "week" },
|
|
{ mode: "custom", limit_usd: 5, period: "day", tz_offset: 9999 },
|
|
{ mode: "nope" },
|
|
]) {
|
|
assert.throws(() => quota.setUserQuota(ids.mia, bad), { code: "EBADQUOTA" });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("spend windows", () => {
|
|
it("a day starts at the zone's local midnight and resets at the next one", () => {
|
|
const now = Date.parse("2026-10-28T03:30:00Z"); // 10:30 at UTC+7
|
|
const w = quota.windowOf({ period: "day", tz_offset: -420 }, now);
|
|
assert.equal(w.since, "2026-10-27T17");
|
|
assert.equal(new Date(w.nextReset).toISOString(), "2026-10-28T17:00:00.000Z");
|
|
});
|
|
|
|
it("5 h is the current hour and the four before it; it opens as the oldest hours leave", () => {
|
|
const now = Date.parse("2026-10-28T10:20:00Z");
|
|
assert.equal(quota.windowOf({ period: "5h" }, now).since, "2026-10-28T06");
|
|
const byHour = new Map([
|
|
["2026-10-28T06", 4],
|
|
["2026-10-28T08", 3],
|
|
["2026-10-28T10", 2],
|
|
]);
|
|
// 9 spent, limit 6: 06's $4 leaving brings it to 5 < 6 → at 06 + 5 h.
|
|
assert.equal(
|
|
new Date(quota.slidingReset(byHour, 9, 6)).toISOString(),
|
|
"2026-10-28T11:00:00.000Z"
|
|
);
|
|
// limit 3: everything up to 08 must go → 08 + 5 h.
|
|
assert.equal(
|
|
new Date(quota.slidingReset(byHour, 9, 3)).toISOString(),
|
|
"2026-10-28T13:00:00.000Z"
|
|
);
|
|
});
|
|
|
|
it("counts the spend of the sessions a user owns, inside the window only", () => {
|
|
db.prepare(
|
|
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('old', 'old', 'completed', '/w/mia', ?, ?)"
|
|
).run(laneId, new Date().toISOString());
|
|
spend("old", 1_000_000, Date.now() - 8 * HOUR); // outside 5 h
|
|
quota.setUserQuota(ids.mia, { mode: "custom", limit_usd: 100, period: "5h" });
|
|
const s = quota.statusFor(ids.mia);
|
|
assert.equal(s.spend, 15);
|
|
assert.equal(s.exceeded, false);
|
|
assert.equal(s.resetsAt, null);
|
|
assert.equal(quota.statusFor(ids.lou), null);
|
|
quota.setUserQuota(ids.mia, { mode: "default" });
|
|
});
|
|
});
|
|
|
|
describe("routes", () => {
|
|
it("an admin sets the default with their password; the list shows each user's quota", async () => {
|
|
const boss = await login("boss", BOSS_PASS);
|
|
const noPass = await call("PUT", "/api/users/quota-default", {
|
|
cookie: boss,
|
|
body: { quota: { limit_usd: 10, period: "day" } },
|
|
});
|
|
assert.equal(noPass.status, 403);
|
|
const bad = await call("PUT", "/api/users/quota-default", {
|
|
cookie: boss,
|
|
body: { quota: { limit_usd: -1, period: "day" }, currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(bad.status, 400);
|
|
assert.equal(bad.body.error.code, "EBADQUOTA");
|
|
const ok = await call("PUT", "/api/users/quota-default", {
|
|
cookie: boss,
|
|
body: { quota: { limit_usd: 10, period: "day" }, currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
|
assert.equal(ok.body.quotaDefault.limit_usd, 10);
|
|
|
|
const list = await call("GET", "/api/users", { cookie: boss });
|
|
assert.equal(list.body.quotaDefault.limit_usd, 10);
|
|
const mia = list.body.users.find((u) => u.id === ids.mia);
|
|
assert.deepEqual(mia.quotaSetting, { mode: "default" });
|
|
assert.equal(mia.quota.spend, 15);
|
|
assert.equal(mia.quota.exceeded, true);
|
|
assert.ok(mia.quota.resetsAt);
|
|
});
|
|
|
|
it("a developer reads their own quota and may not change any", async () => {
|
|
const me = await login("mia", "member-pass-1");
|
|
const r = await call("GET", "/api/me/quota", { cookie: me });
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.quota.exceeded, true);
|
|
const put = await call("PUT", "/api/users/quota-default", {
|
|
cookie: me,
|
|
body: { quota: null, currentPassword: "member-pass-1" },
|
|
});
|
|
assert.equal(put.status, 403);
|
|
});
|
|
|
|
it("PATCH /api/users/:id {quota} overrides one user's, audited", async () => {
|
|
const boss = await login("boss", BOSS_PASS);
|
|
const r = await call("PATCH", `/api/users/${ids.mia}`, {
|
|
cookie: boss,
|
|
body: { quota: { mode: "custom", limit_usd: 40, period: "day" }, currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
assert.equal(quota.statusFor(ids.mia).exceeded, false);
|
|
const audit = db
|
|
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
|
|
.get(ids.mia);
|
|
assert.match(audit.path, /spend quota \$40\/day/);
|
|
const bad = await call("PATCH", `/api/users/${ids.mia}`, {
|
|
cookie: boss,
|
|
body: { quota: { mode: "lots" }, currentPassword: BOSS_PASS },
|
|
});
|
|
assert.equal(bad.status, 400);
|
|
quota.setUserQuota(ids.mia, { mode: "default" }); // back to the $10 default: over
|
|
});
|
|
});
|
|
|
|
describe("enforcement", () => {
|
|
it("refuses a new pane of an owner over quota with EQUOTA and a reason", () => {
|
|
assert.throws(
|
|
() => quota.assertSpawnAllowed(ids.mia),
|
|
(err) => {
|
|
assert.equal(err.code, "EQUOTA");
|
|
assert.match(err.message, /\$15\.00 of \$10\.00 per day/);
|
|
return true;
|
|
}
|
|
);
|
|
quota.assertSpawnAllowed(ids.lou); // under the default: no throw
|
|
quota.assertSpawnAllowed(null); // a workspace with no owner
|
|
});
|
|
|
|
it("gives a pane its owner's flag file in server mode only", () => {
|
|
const env = quota.flagEnv(ids.mia, null);
|
|
assert.match(env.AGENTHUB_QUOTA_FILE, /user-quota[/\\]u\d+\.json$/);
|
|
delete process.env.AGENTHUB_MODE;
|
|
try {
|
|
assert.deepEqual(quota.flagEnv(ids.mia, null), {});
|
|
quota.assertSpawnAllowed(ids.mia); // local mode never refuses
|
|
assert.equal(quota.statusAll().size, 0);
|
|
} finally {
|
|
process.env.AGENTHUB_MODE = "server";
|
|
}
|
|
});
|
|
|
|
it("sweep: writes the flag, ends the panes no hook can stop, keeps Claude's, clears on raise", () => {
|
|
const realList = runs.listRuns;
|
|
const realKill = runs.killRun;
|
|
const killed = [];
|
|
runs.listRuns = () => [
|
|
{ id: `agenthub-lane-${laneId}`, laneId, status: "running", tool: "claude" },
|
|
{ id: `agenthub-lane-${laneId}-1`, laneId, status: "running", tool: "cursor" },
|
|
];
|
|
runs.killRun = (id) => killed.push(id) > 0;
|
|
try {
|
|
quota.__reset();
|
|
const r = quota.sweep();
|
|
assert.deepEqual(r.blocked, [ids.mia]);
|
|
assert.deepEqual(killed, [`agenthub-lane-${laneId}-1`]);
|
|
const file = quota.flagEnv(ids.mia, null).AGENTHUB_QUOTA_FILE;
|
|
const flag = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
assert.equal(flag.blocked, true);
|
|
assert.match(flag.message, /quota is used up/);
|
|
assert.ok(Date.parse(flag.until) > Date.now());
|
|
// Lou never went over: no file is written for them.
|
|
assert.equal(fs.existsSync(quota.flagEnv(ids.lou, null).AGENTHUB_QUOTA_FILE), false);
|
|
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}-3`), true);
|
|
|
|
quota.setUserQuota(ids.mia, { mode: "unlimited" });
|
|
killed.length = 0;
|
|
quota.sweep();
|
|
assert.deepEqual(killed, []);
|
|
assert.equal(JSON.parse(fs.readFileSync(file, "utf8")).blocked, false);
|
|
assert.equal(quota.isRunBlocked(`agenthub-lane-${laneId}`), false);
|
|
} finally {
|
|
runs.listRuns = realList;
|
|
runs.killRun = realKill;
|
|
quota.setUserQuota(ids.mia, { mode: "default" });
|
|
quota.__reset();
|
|
}
|
|
});
|
|
|
|
it("the omp gateway loses a blocked owner's key, never the queue's", () => {
|
|
const { clientsFor } = require("../lib/omp-share");
|
|
const t = { standby: ["s1"], used: { a: { hash: "ha" }, b: { hash: "hb" } } };
|
|
const out = clientsFor(t, (id) => id === "a");
|
|
assert.equal(out.a, undefined);
|
|
assert.deepEqual(out.b, { tokenSha256: "hb" });
|
|
assert.equal(Object.keys(out).filter((k) => k.startsWith("standby-")).length, 1);
|
|
});
|
|
});
|
|
|
|
describe("hook handler", () => {
|
|
const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js");
|
|
const flag = path.join(TMP, "flag.json");
|
|
const run = (args, file) =>
|
|
spawnSync(process.execPath, [HANDLER, ...args], {
|
|
input: "{}",
|
|
env: { ...process.env, AGENTHUB_QUOTA_FILE: file, CLAUDE_DASHBOARD_PORT: "1" },
|
|
encoding: "utf8",
|
|
});
|
|
|
|
it("refuses a Claude Code or Codex prompt while the flag says blocked", () => {
|
|
fs.writeFileSync(
|
|
flag,
|
|
JSON.stringify({ blocked: true, until: "2999-01-01T00:00:00Z", message: "quota used up" })
|
|
);
|
|
for (const args of [["UserPromptSubmit"], ["UserPromptSubmit", "codex"]]) {
|
|
const r = run(args, flag);
|
|
assert.equal(r.status, 2);
|
|
assert.match(r.stderr, /quota used up/);
|
|
}
|
|
});
|
|
|
|
it("lets everything else through: other events, Antigravity, an expired or missing flag", () => {
|
|
assert.equal(run(["PreToolUse"], flag).status, 0);
|
|
assert.equal(run(["UserPromptSubmit", "antigravity"], flag).status, 0);
|
|
assert.equal(run(["UserPromptSubmit"], path.join(TMP, "none.json")).status, 0);
|
|
fs.writeFileSync(flag, JSON.stringify({ blocked: true, until: "2000-01-01T00:00:00Z" }));
|
|
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
|
|
fs.writeFileSync(flag, "{not json");
|
|
assert.equal(run(["UserPromptSubmit"], flag).status, 0);
|
|
});
|
|
});
|