feat(server-mode): spend quota per user — no new session, prompts refused once used up

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.
This commit is contained in:
2026-10-28 14:31:11 +07:00
parent 9e3d6f4ca1
commit fa27b299e7
29 changed files with 2044 additions and 23 deletions
+49
View File
@@ -710,6 +710,55 @@ omp login # e.g. anthropic
> runs; it cannot carry a login away. A member who edits the `agenthub`
> provider out of their `models.yml` gets it back at their next omp pane.
## Spend quotas
An admin limits what each user may spend, in dollars, on the Users page
(`server/lib/user-quota.js`):
- **Default spend quota** card: one limit every user without their own gets
(`PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null}`).
- **Edit user → Spend quota**: *Default*, *No limit*, or *Their own limit*
(`PATCH /api/users/:id` `{quota: {mode: "default" | "unlimited" | "custom", …}}`).
- A period is `day` (from local midnight of the zone the admin's browser was
in when they saved it) or `5h` (sliding: the current UTC hour and the four
before it — spend booked in hour H counts until H + 5 h).
- Both writes need the admin's own password again and are audited, like
every account change; each one re-applies at once (`sweep`).
Spend is the users page's own figure: the tokens of the sessions the user
OWNS through a workspace, priced by `calculateCost`. The Users table shows
each user's spend against their quota; the user sees their own as **Quota N%**
in the status bar (`GET /api/me/quota`).
Once a user's spend reaches the limit, until the window opens again:
- **No new session.** Every start, resume or prompt from the workspace home
goes through `spawnRun`, which answers `429 EQUOTA` with the reason.
- **Claude Code and Codex prompts are refused** by the hooks. Every pane
carries `AGENTHUB_QUOTA_FILE`. The dashboard writes that file every minute:
the host's under `<data dir>/user-quota/u<id>.json`, a member's (S3) in
their handoff dir through `write-as`. While it says `blocked`,
`scripts/hook-handler.js` exits 2 on `UserPromptSubmit` with the reason on
stderr, and the CLI drops the prompt and shows why. The hook reads only that
local file and never asks the dashboard. It fails open: a missing or
unreadable file, or a passed `until`, lets the prompt through. Codex takes
this only with AgentHub's Codex hooks installed and trusted.
- **A member's omp on the shared login** (above) loses its gateway key: the
run's key is left out of `clients.json` until the quota opens. Its pane stays.
- **Every other pane of theirs is ended.** This covers Antigravity, Cursor, a
Codex without trusted hooks (a member's Codex always), an omp on its own
login, and any other catalog tool. Plain shells are left alone.
Limits of the check:
- Tokens land after a turn, so a user can finish one turn past the limit
(omp/Codex journals are polled, so a little more).
- A user's own terminal outside AgentHub carries no flag file.
- Without S3 every pane runs as the host account, which can edit the flag.
Turn the minute sweep off with `DASHBOARD_QUOTA_SWEEP_MS=0`. The `EQUOTA`
refusal at start does not depend on it.
## One Linux user per account
Every pane of a user, an agent's shell included, runs as that user's own Linux