From fa27b299e705d2ced7c6d4d5004322c86febcff9 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 28 Oct 2026 14:31:11 +0700 Subject: [PATCH] =?UTF-8?q?feat(server-mode):=20spend=20quota=20per=20user?= =?UTF-8?q?=20=E2=80=94=20no=20new=20session,=20prompts=20refused=20once?= =?UTF-8?q?=20used=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ARCHITECTURE.md | 1 + CLAUDE.md | 3 +- client/src/components/StatusBar.tsx | 33 +- .../components/__tests__/StatusBar.test.tsx | 24 + .../src/components/users/UserEditDialog.tsx | 90 +++- client/src/components/users/UserQuota.tsx | 269 ++++++++++ .../users/__tests__/UserQuota.test.tsx | 157 ++++++ client/src/i18n/locales/en/nav.json | 7 + client/src/i18n/locales/en/users.json | 37 +- client/src/i18n/locales/vi/nav.json | 7 + client/src/i18n/locales/vi/users.json | 37 +- client/src/lib/api.ts | 39 +- client/src/pages/Users.tsx | 22 +- docs/API.md | 3 +- docs/SERVER-MODE.md | 49 ++ openapi.yaml | 179 ++++++- scripts/hook-handler.js | 34 ++ server/__tests__/user-quota.test.js | 343 +++++++++++++ server/db.js | 17 + server/index.js | 20 + server/lib/omp-share.js | 20 +- server/lib/pty-run.js | 5 + server/lib/user-quota.js | 467 ++++++++++++++++++ server/openapi-extra/auth.js | 121 ++++- server/openapi-extra/run.js | 3 + server/routes/lanes.js | 5 +- server/routes/me.js | 12 + server/routes/run.js | 4 + server/routes/users.js | 59 ++- 29 files changed, 2044 insertions(+), 23 deletions(-) create mode 100644 client/src/components/users/UserQuota.tsx create mode 100644 client/src/components/users/__tests__/UserQuota.test.tsx create mode 100644 server/__tests__/user-quota.test.js create mode 100644 server/lib/user-quota.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f108cf23..17081163 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -327,6 +327,7 @@ graph TD | `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks | | `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately | | `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` | +| `lib/user-quota.js` | Server mode's spend quota per user (`user_quotas`, row 0 = the default; `PUT /api/users/quota-default`, `PATCH /api/users/:id {quota}`, `GET /api/me/quota`): `limit_usd` per local `day` or sliding `5h`, spend = the users page's `hourlyTokenRows` + `calculateCost` over owned sessions. Over it: `spawnRun` throws `EQUOTA` (429); a 60 s `sweep` writes each pane's `AGENTHUB_QUOTA_FILE` (the hook handler then exits 2 on `UserPromptSubmit`, from the file alone, fail-open), leaves the owner's omp keys out of the gateway's clients file and ends panes no hook can stop | | `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing | | `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` | | `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes | diff --git a/CLAUDE.md b/CLAUDE.md index 3f837c92..6c909a1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.30 (2026-10-28) — the account pool is one board of pools (split/merge by drag), and a Claude plan is read from its tier and seat._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.30 (2026-10-28) — spend quotas per user (server mode): EQUOTA at start, prompts refused by the hook's local flag file._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -31,6 +31,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **Push ingest takes only what a token holder may claim.** `POST /api/hooks/ingest-batch` answers 503 until a token exists; the token (`server/lib/push-token.js`) is shown ONCE and stored as a SHA-256 hash, accepted only from the `Authorization: Bearer` header and compared in constant time. A pushed hook may extend only a session its own `push:` created (`EFOREIGN`), its `transcript_path` is dropped so no local file is ever read from it, and it never touches a lane or a pane. Do not add a GET that returns the token or a route that takes it from a query string. - **A snapshot is immutable and named by an unguessable token.** `session_snapshots` rows are written once (a trigger refuses UPDATE) under 24 random bytes base64url; redaction is applied AT CAPTURE so a stored blob can never be un-redacted; writes are same-origin. Do not add an update route or a token derived from the session id. - **A budget is an alert rule, not a parallel system.** `budget` and `cost_threshold` live in `alert_rules` (validated by `validateRuleConfig`, the table has no rule_type CHECK any more) and fire through `fireAlert` → feed, webhooks, push; a budget fires each threshold ONCE per period (deduped on `details.period_start` + `details.threshold`). Spend is always `dailyTokenRows` + `calculateCost` — never a second cost formula. `POST /api/alerts/rules/preview` may never write, fire or broadcast. +- **A spend quota is enforcement, decided locally, and the hook stays fail-open.** `server/lib/user-quota.js` (server mode only; `user_quotas`, row 0 = the default): an owner over their quota gets `EQUOTA` (429) from `spawnRun`, and the minute `sweep` writes `AGENTHUB_QUOTA_FILE` (host: `/user-quota/u.json`; member: their handoff dir through `write-as`), drops a blocked owner's omp gateway keys (`isRunBlocked`) and ends only panes no hook can stop (`hookCanStop`). `hook-handler.js` refuses a `UserPromptSubmit` (exit 2) from that FILE alone — never by waiting on the dashboard, never for Antigravity, and a missing/bad/expired flag lets the prompt through. Spend is the users page's `hourlyTokenRows` + `calculateCost`, never a second formula. - **A report file path is built from ids only.** `server/lib/reports.js` writes under `/reports//` and the download route resolves a run by its ids; no route takes a path or file name from a request, and every value in a report's HTML is escaped. - **AgentHub ships no third-party pet art, and a pet comes only from an upload or a fixed featured id.** The featured pets are codex-pets.net uploads with no redistribution licence (one is a copyrighted character), so `server/lib/pets.js` downloads them into the user's data dir on a pick — never commit their files, and never add a pet route that takes a URL or a path. Uploads are raster only (an SVG served from the dashboard's origin runs script), a bundle's entries are size-checked from the zip directory before they are inflated, and pet ids are made by the server. - **AgentHub does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`agenthub lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them. diff --git a/client/src/components/StatusBar.tsx b/client/src/components/StatusBar.tsx index b923bb8b..d5b85609 100644 --- a/client/src/components/StatusBar.tsx +++ b/client/src/components/StatusBar.tsx @@ -22,6 +22,9 @@ * its fullest window, live account only — and, on hover or focus, a panel * above grouping every account per tool (saved Claude profiles too): name, * sign-in expiry, and a row per window with its bar, percent and reset time. + * Server mode: the user's OWN spend quota (`GET /api/me/quota`, read with the + * costs) as "Quota N%" — amber from 80 %, red once used up, when the user can + * start nothing and their prompts are refused until it opens again. * @author Nguyễn Ngọc Trí Vĩ */ @@ -30,9 +33,9 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router-dom"; import { RefreshCw, Wifi, WifiOff } from "lucide-react"; -import { api, type RunHandle } from "../lib/api"; +import { api, type RunHandle, type UserQuotaStatus } from "../lib/api"; import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types"; -import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format"; +import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format"; import { AiToolIcon } from "./AiToolIcon"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { useSpin } from "../hooks/useSpin"; @@ -369,6 +372,8 @@ export function StatusBar({ const [runs, setRuns] = useState([]); // The budget furthest past one of its thresholds, if any. const [budget, setBudget] = useState(null); + // Server mode: this user's own spend quota (null: none, or local mode). + const [myQuota, setMyQuota] = useState(null); const location = useLocation(); const shown = selectedFrom(location.pathname, location.search); const lane = shown.lane; @@ -414,6 +419,10 @@ export function StatusBar({ if (live()) setBudget(hit[0] ?? null); }) .catch(() => undefined); + api.me + .quota() + .then((r) => live() && setMyQuota(r?.quota ?? null)) + .catch(() => undefined); if (lane !== null) { api.pricing .totalCost({ lane }) @@ -524,6 +533,26 @@ export function StatusBar({ {t("statusBar.budget", { pct: Math.round(budget.pct) })} )} + {myQuota && ( + = 80 + ? "text-status-warning" + : "text-fg-muted" + }`} + > + {t("statusBar.userQuota", { pct: Math.min(999, Math.round(myQuota.pct)) })} + + )} ); diff --git a/client/src/components/__tests__/StatusBar.test.tsx b/client/src/components/__tests__/StatusBar.test.tsx index 9d78c647..e10e2aea 100644 --- a/client/src/components/__tests__/StatusBar.test.tsx +++ b/client/src/components/__tests__/StatusBar.test.tsx @@ -129,6 +129,20 @@ vi.mock("../../lib/api", () => ({ daily_costs: [], })), }, + me: { + // Server mode: this user's own spend quota, used up. + quota: vi.fn().mockResolvedValue({ + quota: { + limit_usd: 10, + period: "day", + spend: 12, + pct: 120, + exceeded: true, + resetsAt: "2026-10-29T17:00:00.000Z", + since: "2026-10-28T17:00:00.000Z", + }, + }), + }, budgets: { status: vi.fn().mockResolvedValue({ budgets: [ @@ -183,6 +197,16 @@ describe("StatusBar", () => { expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period"); }); + it("shows the user's own spend quota, red once used up", async () => { + at("/", {}} />); + const chip = await screen.findByTestId("status-bar-user-quota"); + expect(chip.textContent).toBe("Quota 120%"); + expect(chip.className).toContain("text-status-danger"); + expect(chip.getAttribute("title")).toMatch( + /^Your spend quota is used up \(\$12\.00 of \$10\.00 today\)/ + ); + }); + it("says Disconnected when the socket is down", () => { at("/", {}} />); expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument(); diff --git a/client/src/components/users/UserEditDialog.tsx b/client/src/components/users/UserEditDialog.tsx index df7e86a0..bf95e0e3 100644 --- a/client/src/components/users/UserEditDialog.tsx +++ b/client/src/components/users/UserEditDialog.tsx @@ -5,7 +5,9 @@ * account), a new password (typed or generated, copyable — someone else's * may be anything and makes them choose their own at next sign-in; your own * must pass every rule of `lib/passwordRules.ts`), the workspaces the - * account owns (remove, add from every workspace), and "sign out everywhere". + * account owns (remove, add from every workspace), its spend quota (the + * default, no limit, or its own dollars per day / 5 h — `UserQuota.tsx`), and + * "sign out everywhere". * Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only * what changed, confirmed with the admin's OWN password (`currentPassword`) — * the server validates the whole change before it applies any of it. A @@ -17,7 +19,14 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNod import { useTranslation } from "react-i18next"; import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react"; import { confirmDialog } from "../../lib/confirm"; -import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api"; +import { + api, + type AuthUser, + type ServerUserUsage, + type UserPatch, + type UserQuota, + type UserQuotaSetting, +} from "../../lib/api"; import type { Lane } from "../../lib/types"; import { fmtCost, formatDateTime } from "../../lib/format"; import { copyText } from "../../lib/clipboard"; @@ -27,6 +36,7 @@ import { PasswordRules } from "./PasswordRules"; import { useDialog } from "../../hooks/useDialog"; import { Switch } from "../Switch"; import { Checkbox } from "../Checkbox"; +import { QuotaFields, parseLimit, useQuotaLabel, type QuotaPeriod } from "./UserQuota"; type Role = AuthUser["role"]; const ROLES: Role[] = ["admin", "analyst", "developer"]; @@ -55,10 +65,13 @@ export function UserEditDialog({ user, self, users, + quotaDefault = null, onClose, onDone, }: { user: ServerUserUsage; + /** The default spend quota, named beside the "Default" choice. */ + quotaDefault?: UserQuota | null; /** The account being edited is the signed-in admin's own. */ self: boolean; users: ServerUserUsage[]; @@ -81,6 +94,18 @@ export function UserEditDialog({ const [added, setAdded] = useState([]); const [pick, setPick] = useState(""); const [signOut, setSignOut] = useState(false); + // Spend quota: absent from an older server, which then gets no quota field. + const quotaSetting = user.quotaSetting; + const [quotaMode, setQuotaMode] = useState( + quotaSetting?.mode ?? "default" + ); + const [quotaLimit, setQuotaLimit] = useState( + quotaSetting?.mode === "custom" ? String(quotaSetting.limit_usd) : "" + ); + const [quotaPeriod, setQuotaPeriod] = useState( + quotaSetting?.mode === "custom" ? quotaSetting.period : (quotaDefault?.period ?? "day") + ); + const quotaLabel = useQuotaLabel(); const [current, setCurrent] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -117,12 +142,33 @@ export function UserEditDialog({ if (added.length) patch.addWorkspaces = added; if (removed.size) patch.removeWorkspaces = [...removed]; if (signOut) patch.signOut = true; + const quotaValue = parseLimit(quotaLimit); + const quotaBad = quotaMode === "custom" && quotaValue === null; + if (quotaSetting && !quotaBad) { + const same = + quotaMode === quotaSetting.mode && + (quotaMode !== "custom" || + (quotaSetting.mode === "custom" && + quotaSetting.limit_usd === quotaValue && + quotaSetting.period === quotaPeriod)); + if (!same) { + patch.quota = + quotaMode === "custom" + ? { + mode: "custom", + limit_usd: quotaValue as number, + period: quotaPeriod, + tz_offset: new Date().getTimezoneOffset(), + } + : { mode: quotaMode }; + } + } const changed = Object.keys(patch).length > 0; // A blank field keeps the password. Someone else's may be anything (they // choose their own at next sign-in); your own must pass every rule. const weakOwn = self && password.length > 0 && passwordProblems(password, user.username).length > 0; - const ready = changed && current.length > 0 && !weakOwn && !busy; + const ready = changed && current.length > 0 && !weakOwn && !quotaBad && !busy; const submit = async () => { if (!ready) return; @@ -479,6 +525,44 @@ export function UserEditDialog({ )} + {quotaSetting && ( +
+
+ + {quotaMode === "custom" && ( + + )} + + {user.quota + ? t("quota.now", { + spend: fmtCost(user.quota.spend), + limit: quotaLabel(user.quota), + }) + : t("quota.enforceHint")} + +
+
+ )} +
+ */ + +import { useState, type KeyboardEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { Gauge, Pencil } from "lucide-react"; +import { api, type UserQuota, type UserQuotaStatus } from "../../lib/api"; +import { fmtCost, formatDateTime } from "../../lib/format"; +import { isSubmitEnter } from "../../lib/keys"; +import { Checkbox } from "../Checkbox"; +import { ConfirmModal } from "../ConfirmModal"; +import { Meter } from "../health/HostHealth"; + +export type QuotaPeriod = UserQuota["period"]; +export const QUOTA_PERIODS: QuotaPeriod[] = ["day", "5h"]; + +/** "$10 / day", "$2.50 / 5 h". */ +export function useQuotaLabel() { + const { t } = useTranslation("users"); + return (q: Pick) => + t(`quota.per.${q.period}`, { limit: fmtCost(q.limit_usd) }); +} + +/** A row's quota: spent of the limit, a bar, and when a used-up one opens again. */ +export function QuotaSummary({ quota }: { quota: UserQuotaStatus | null | undefined }) { + const { t } = useTranslation("users"); + const label = useQuotaLabel(); + if (quota === undefined) return null; + if (quota === null) { + return
{t("quota.none")}
; + } + return ( +
+
+ {t("quota.used", { spend: fmtCost(quota.spend), limit: label(quota) })} + {quota.source === "default" && {t("quota.defaultTag")}} +
+ +
+ ); +} + +/** A dollar limit and its period. `limit` is the raw input text. */ +export function QuotaFields({ + limit, + period, + onLimit, + onPeriod, + onKeyDown, + disabled, +}: { + limit: string; + period: QuotaPeriod; + onLimit: (v: string) => void; + onPeriod: (v: QuotaPeriod) => void; + onKeyDown?: (e: KeyboardEvent) => void; + disabled?: boolean; +}) { + const { t } = useTranslation("users"); + return ( +
+
+ + $ + + onLimit(e.target.value)} + onKeyDown={onKeyDown} + aria-label={t("quota.limit")} + /> +
+ +
+ ); +} + +/** A positive dollar amount from the input text, or null. */ +export function parseLimit(text: string): number | null { + const n = Number(text); + return text.trim() !== "" && Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null; +} + +/** The default quota: shown to staff, edited by an admin with their own password. */ +export function QuotaDefaultCard({ + quotaDefault, + isAdmin, + onSaved, +}: { + /** `undefined` while loading, null for none. */ + quotaDefault: UserQuota | null | undefined; + isAdmin: boolean; + onSaved: (message: string) => void; +}) { + const { t } = useTranslation("users"); + const label = useQuotaLabel(); + const [editing, setEditing] = useState(false); + return ( +
+
+ +
+

{t("quota.cardTitle")}

+

{t("quota.cardDescription")}

+
+
+ {quotaDefault === undefined + ? "…" + : quotaDefault + ? label(quotaDefault) + : t("quota.noDefault")} +
+ {isAdmin && quotaDefault !== undefined && ( + + )} +
+ {editing && ( + setEditing(false)} + onDone={(message) => { + setEditing(false); + onSaved(message); + }} + /> + )} +
+ ); +} + +function QuotaDefaultDialog({ + current, + onClose, + onDone, +}: { + current: UserQuota | null; + onClose: () => void; + onDone: (message: string) => void; +}) { + const { t } = useTranslation("users"); + const [on, setOn] = useState(current !== null); + const [limit, setLimit] = useState(current ? String(current.limit_usd) : ""); + const [period, setPeriod] = useState(current?.period ?? "day"); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const value = parseLimit(limit); + const ready = password.length > 0 && (!on || value !== null) && !busy; + + const submit = async () => { + if (!ready) return; + setBusy(true); + setError(null); + try { + await api.users.setQuotaDefault( + on && value !== null + ? { limit_usd: value, period, tz_offset: new Date().getTimezoneOffset() } + : null, + password + ); + onDone(t("quota.defaultSaved")); + } catch (e) { + const err = e as Error & { code?: string }; + setError( + err.code && t(`errors.${err.code}`, { defaultValue: "" }) + ? t(`errors.${err.code}`) + : err.message + ); + } finally { + setBusy(false); + } + }; + const onKeyDown = (e: KeyboardEvent) => { + if (isSubmitEnter(e)) { + e.preventDefault(); + void submit(); + } + }; + + return ( + void submit()} + onCancel={onClose} + > +
+ + +

{t("quota.enforceHint")}

+ + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/client/src/components/users/__tests__/UserQuota.test.tsx b/client/src/components/users/__tests__/UserQuota.test.tsx new file mode 100644 index 00000000..5c05af43 --- /dev/null +++ b/client/src/components/users/__tests__/UserQuota.test.tsx @@ -0,0 +1,157 @@ +/** + * @file UserQuota.test.tsx + * @description Spend quotas on the Users page: a row's "spent of limit" line + * (red once used up, "(default)" when it is the default), the default quota + * card (an admin edits it with their own password, an analyst only reads), + * and the Edit user form sending a user's own quota as part of its ONE PATCH. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ServerUserUsage } from "../../../lib/api"; + +const users = vi.hoisted(() => ({ + setQuotaDefault: vi.fn(), + update: vi.fn(), + get: vi.fn(), +})); +const lanes = vi.hoisted(() => ({ list: vi.fn() })); +vi.mock("../../../lib/api", async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, api: { users, lanes } }; +}); + +import { QuotaDefaultCard, QuotaSummary } from "../UserQuota"; +import { UserEditDialog } from "../UserEditDialog"; + +beforeEach(() => { + vi.clearAllMocks(); + users.setQuotaDefault.mockResolvedValue({ user: {}, quotaDefault: null }); + users.update.mockResolvedValue({ user: {} }); + users.get.mockResolvedValue({ user: {}, workspaces: [] }); + lanes.list.mockResolvedValue({ lanes: [] }); +}); + +describe("QuotaSummary", () => { + it("shows spent of the limit, red and marked default once used up", () => { + render( + + ); + const line = screen.getByText(/of \$10\.00 \/ day/); + expect(line.textContent).toContain("$12.50"); + expect(line.textContent).toContain("(default)"); + expect(line.className).toContain("text-status-danger"); + expect(screen.getByRole("meter")).toHaveAttribute("aria-valuenow", "100"); + }); + + it("says No quota without one, and nothing for an older server", () => { + const { container, rerender } = render(); + expect(screen.getByText("No quota")).toBeInTheDocument(); + rerender(); + expect(container.textContent).toBe(""); + }); +}); + +describe("QuotaDefaultCard", () => { + it("an admin sets the default with their own password", async () => { + const user = userEvent.setup(); + const onSaved = vi.fn(); + render(); + expect(screen.getByTestId("quota-default-value").textContent).toBe("No default — no limit"); + await user.click(screen.getByRole("button", { name: /Edit/ })); + await user.click(screen.getByRole("checkbox", { name: "Limit what each user may spend" })); + await user.type(screen.getByLabelText("Limit in dollars"), "25"); + await user.selectOptions(screen.getByLabelText("Period"), "5h"); + const save = screen.getByRole("button", { name: "Save" }); + expect(save).toBeDisabled(); // no password yet + await user.type(screen.getByLabelText("Your password"), "pw"); + await user.click(save); + await waitFor(() => expect(onSaved).toHaveBeenCalled()); + expect(users.setQuotaDefault).toHaveBeenCalledWith( + expect.objectContaining({ limit_usd: 25, period: "5h" }), + "pw" + ); + }); + + it("an analyst only reads it", () => { + render( + {}} + /> + ); + expect(screen.getByTestId("quota-default-value").textContent).toBe("$5.00 / day"); + expect(screen.queryByRole("button", { name: /Edit/ })).toBeNull(); + }); +}); + +describe("UserEditDialog quota", () => { + const mia: ServerUserUsage = { + id: 7, + username: "mia", + role: "developer", + disabled: false, + workspaces: 0, + sessions: 0, + last7d: { cost: 0, tokens: 0 }, + last30d: { cost: 0, tokens: 0 }, + quotaSetting: { mode: "default" }, + quota: null, + } as ServerUserUsage; + + it("sends the user's own limit in the one PATCH", async () => { + const user = userEvent.setup(); + const onDone = vi.fn(); + render( + {}} + onDone={onDone} + /> + ); + const select = screen.getByLabelText("Spend quota"); + expect(screen.getByRole("option", { name: "Default ($10.00 / day)" })).toBeInTheDocument(); + await user.selectOptions(select, "custom"); + await user.type(screen.getByLabelText("Limit in dollars"), "40"); + await user.type(screen.getByLabelText("Your password"), "pw"); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(onDone).toHaveBeenCalled()); + expect(users.update).toHaveBeenCalledWith( + 7, + { quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }) }, + "pw" + ); + }); + + it("No limit is a change; an empty custom limit keeps Save off", async () => { + const user = userEvent.setup(); + render( + {}} onDone={() => {}} /> + ); + await user.type(screen.getByLabelText("Your password"), "pw"); + await user.selectOptions(screen.getByLabelText("Spend quota"), "custom"); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + await user.selectOptions(screen.getByLabelText("Spend quota"), "unlimited"); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } }, "pw") + ); + }); +}); diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json index 44985b02..3fab144a 100644 --- a/client/src/i18n/locales/en/nav.json +++ b/client/src/i18n/locales/en/nav.json @@ -78,6 +78,13 @@ "quotaWindow": { "wk": "Week", "mo": "Month" + }, + "userQuota": "Quota {{pct}}%", + "userQuotaTitle": "Your spend quota: {{spend}} of {{limit}} {{period}}", + "userQuotaBlocked": "Your spend quota is used up ({{spend}} of {{limit}} {{period}}): no new session and no prompt until {{at}}", + "userQuotaPeriod": { + "day": "today", + "5h": "in the last 5 hours" } }, "version": "Version", diff --git a/client/src/i18n/locales/en/users.json b/client/src/i18n/locales/en/users.json index e7459d97..b7bddef2 100644 --- a/client/src/i18n/locales/en/users.json +++ b/client/src/i18n/locales/en/users.json @@ -87,7 +87,8 @@ "ELOCALMODE": "Accounts are managed in server mode only.", "ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.", "EWEAKPASSWORD": "The new password does not meet every rule (or is the current one).", - "EPWCHANGE": "Choose your own password first." + "EPWCHANGE": "Choose your own password first.", + "EBADQUOTA": "Enter a positive dollar limit and a period of a day or 5 hours." }, "readOnly": "Analysts can view accounts but not change them.", "auditTitle": "Audit log", @@ -412,5 +413,37 @@ "spendSub": "7 d {{week}} · 30 d {{month}}", "sessionsN": "{{n}} sessions", "workspacesN": "{{n}} workspaces", - "tmuxCol": "tmux" + "tmuxCol": "tmux", + "quota": { + "title": "Spend quota", + "limit": "Limit in dollars", + "period": "Period", + "periods": { + "day": "per day (from local midnight)", + "5h": "per 5 hours (sliding)" + }, + "per": { + "day": "{{limit}} / day", + "5h": "{{limit}} / 5 h" + }, + "used": "{{spend}} of {{limit}}", + "defaultTag": "(default)", + "none": "No quota", + "blockedUntil": "Used up — no new session and no prompt until {{at}}", + "cardTitle": "Default spend quota", + "cardDescription": "Every user without a quota of their own gets this one. Once it is used up they can start no session and their prompts are refused until it resets; a pane no hook can stop (Antigravity, Cursor, Codex without trusted hooks, omp on its own login) is ended.", + "noDefault": "No default — no limit", + "editDefault": "Edit", + "defaultDialog": "Default spend quota", + "defaultOn": "Limit what each user may spend", + "defaultSaved": "Default spend quota saved", + "enforceHint": "Over the quota: no new session, Claude Code and Codex prompts refused, other AI tools' panes ended — until it resets.", + "modes": { + "defaultOf": "Default ({{quota}})", + "defaultNone": "Default (no limit)", + "unlimited": "No limit", + "custom": "Their own limit" + }, + "now": "Now: {{spend}} of {{limit}}" + } } diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json index 853a4a11..dbefe3c1 100644 --- a/client/src/i18n/locales/vi/nav.json +++ b/client/src/i18n/locales/vi/nav.json @@ -78,6 +78,13 @@ "quotaWindow": { "wk": "Tuần", "mo": "Tháng" + }, + "userQuota": "Quota {{pct}}%", + "userQuotaTitle": "Quota chi phí của bạn: {{spend}} trên {{limit}} {{period}}", + "userQuotaBlocked": "Bạn đã dùng hết quota chi phí ({{spend}} trên {{limit}} {{period}}): không mở phiên mới, không gửi prompt đến {{at}}", + "userQuotaPeriod": { + "day": "hôm nay", + "5h": "trong 5 giờ qua" } }, "version": "Phiên bản", diff --git a/client/src/i18n/locales/vi/users.json b/client/src/i18n/locales/vi/users.json index 141fb8f3..2ab01eec 100644 --- a/client/src/i18n/locales/vi/users.json +++ b/client/src/i18n/locales/vi/users.json @@ -87,7 +87,8 @@ "ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.", "ELASTADMIN": "Không thể xoá, hạ quyền hay khoá admin cuối cùng.", "EWEAKPASSWORD": "Mật khẩu mới chưa đạt đủ các yêu cầu (hoặc trùng mật khẩu hiện tại).", - "EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước." + "EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước.", + "EBADQUOTA": "Nhập hạn mức USD lớn hơn 0 và chu kỳ ngày hoặc 5 giờ." }, "readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.", "auditTitle": "Nhật ký audit", @@ -412,5 +413,37 @@ "spendSub": "7 ngày {{week}} · 30 ngày {{month}}", "sessionsN": "{{n}} session", "workspacesN": "{{n}} workspace", - "tmuxCol": "tmux" + "tmuxCol": "tmux", + "quota": { + "title": "Quota chi phí", + "limit": "Hạn mức (USD)", + "period": "Chu kỳ", + "periods": { + "day": "mỗi ngày (từ nửa đêm giờ địa phương)", + "5h": "mỗi 5 giờ (trượt)" + }, + "per": { + "day": "{{limit}} / ngày", + "5h": "{{limit}} / 5 giờ" + }, + "used": "{{spend}} trên {{limit}}", + "defaultTag": "(mặc định)", + "none": "Không giới hạn", + "blockedUntil": "Đã hết — không mở phiên mới, không gửi prompt đến {{at}}", + "cardTitle": "Quota chi phí mặc định", + "cardDescription": "Áp cho mọi user không có quota riêng. Khi dùng hết, user không mở được phiên mới và prompt bị từ chối đến khi quota mở lại; pane không hook nào chặn được (Antigravity, Cursor, Codex chưa tin hook, omp dùng login riêng) sẽ bị dừng.", + "noDefault": "Chưa có mặc định — không giới hạn", + "editDefault": "Sửa", + "defaultDialog": "Quota chi phí mặc định", + "defaultOn": "Giới hạn chi phí của mỗi user", + "defaultSaved": "Đã lưu quota chi phí mặc định", + "enforceHint": "Vượt quota: không mở phiên mới, prompt Claude Code và Codex bị từ chối, pane của các AI tool khác bị dừng — đến khi quota mở lại.", + "modes": { + "defaultOf": "Mặc định ({{quota}})", + "defaultNone": "Mặc định (không giới hạn)", + "unlimited": "Không giới hạn", + "custom": "Hạn mức riêng" + }, + "now": "Hiện tại: {{spend}} trên {{limit}}" + } } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 76b6fe7d..3272be34 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -545,6 +545,31 @@ export type ServerUserUsage = AuthUser & { /** Active login sessions (cookies not expired). */ logins?: number; lastLoginAt?: string | null; + /** This user's own spend quota setting (absent from an older server). */ + quotaSetting?: UserQuotaSetting; + /** The quota that binds them now with its spend; null = no limit. */ + quota?: UserQuotaStatus | null; +}; +/** A spend quota: `limit_usd` per day (from local midnight of `tz_offset`) or per sliding 5 h. */ +export type UserQuota = { + limit_usd: number; + period: "day" | "5h"; + /** getTimezoneOffset() minutes of the zone a `day` starts in. */ + tz_offset?: number; + /** Where it comes from: the default, or the user's own. */ + source?: "default" | "user"; +}; +/** A user's own setting: the default applies, no limit, or a limit of their own. */ +export type UserQuotaSetting = + { mode: "default" } | { mode: "unlimited" } | ({ mode: "custom" } & UserQuota); +/** A quota now (`GET /api/users`, `GET /api/me/quota`). */ +export type UserQuotaStatus = UserQuota & { + spend: number; + pct: number; + /** Used up: no new session starts and prompts are refused until `resetsAt`. */ + exceeded: boolean; + resetsAt: string | null; + since: string; }; /** A priced slice of a user's spend (`GET /api/users/:id/details`). */ export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number }; @@ -773,6 +798,8 @@ export type UserPatch = { addWorkspaces?: number[]; removeWorkspaces?: number[]; signOut?: true; + /** Their own spend quota (`default` = the default applies). */ + quota?: UserQuotaSetting; }; /** GET /api/me/account — the signed-in account's own information. */ export type MyAccount = @@ -1210,6 +1237,8 @@ export const api = { /** PUT /api/me/prefs — merge; `null` deletes a key. */ savePrefs: (prefs: Record) => request("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }), + /** GET /api/me/quota — this account's own spend quota now (null: no limit, or local mode). */ + quota: () => request<{ quota: UserQuotaStatus | null }>("/me/quota"), /** GET /api/me/instructions — this account's own agent instructions (issue #67). */ instructions: () => request("/me/instructions"), /** PUT /api/me/instructions — blank clears; put before every workspace's own. */ @@ -1271,7 +1300,15 @@ export const api = { avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null, /** GET /api/users — every account with its recent usage (admin/analyst). */ list: () => - request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`), + request<{ users: ServerUserUsage[]; quotaDefault?: UserQuota | null }>( + `/users?tz_offset=${new Date().getTimezoneOffset()}` + ), + /** PUT /api/users/quota-default — the default spend quota (null = none). */ + setQuotaDefault: (quota: UserQuota | null, currentPassword: string) => + request<{ user: AuthUser; quotaDefault: UserQuota | null }>("/users/quota-default", { + method: "PUT", + body: JSON.stringify({ quota, currentPassword }), + }), /** GET /api/users/:id/details — one account in full (admin/analyst). */ details: (id: number) => request(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`), diff --git a/client/src/pages/Users.tsx b/client/src/pages/Users.tsx index 7206ba3b..ef014fbd 100644 --- a/client/src/pages/Users.tsx +++ b/client/src/pages/Users.tsx @@ -17,7 +17,9 @@ * an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile, * role, active/disabled, password, owned workspaces, sign out everywhere, sent * as one change), and the Claude/Codex account pool every user's sessions run - * on (`AccountPoolCard`). + * on (`AccountPoolCard`). Each row's spend shows its spend quota (spent of the + * limit, red once used up) and the default quota sits in its own card + * (`UserQuota.tsx`); a user's own quota is part of Edit. * Every change asks for the admin's OWN password again (the * server refuses without it); an analyst only reads. A row shows the display * name and email under the username when set, and "New password due" while @@ -61,6 +63,7 @@ import { type AuthUser, type MemberTmuxRow, type ServerUserUsage, + type UserQuota, type UserStorage, } from "../lib/api"; import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format"; @@ -74,6 +77,7 @@ import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog"; import { AccountPoolCard } from "../components/users/AccountPoolCard"; import { UserDetail } from "../components/users/UserDetail"; import { UserAvatar } from "../components/users/UserAvatar"; +import { QuotaDefaultCard, QuotaSummary } from "../components/users/UserQuota"; import { MemberTmuxBar, TmuxCell, @@ -104,6 +108,8 @@ export function Users({ const isAdmin = me?.role === "admin"; const [users, setUsers] = useState(null); + // The default spend quota (`undefined` until loaded or from an older server). + const [quotaDefault, setQuotaDefault] = useState(undefined); const [audit, setAudit] = useState(null); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); @@ -151,6 +157,7 @@ export function Users({ .list() .then((r) => { setUsers(r.users); + setQuotaDefault(r.quotaDefault); setError(null); }) .catch((e: Error) => setError(e.message)), @@ -406,6 +413,7 @@ export function Users({ month: fmtCost(u.last30d.cost), })} + {showTmux && ( @@ -487,6 +495,17 @@ export function Users({ ); })()} + {quotaDefault !== undefined && ( + { + toast.success(message); + void load(); + }} + /> + )} + {children} @@ -559,6 +578,7 @@ export function Users({ {editing && ( setEditing(null)} diff --git a/docs/API.md b/docs/API.md index 267878c8..d90832fe 100644 --- a/docs/API.md +++ b/docs/API.md @@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S | `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode | | `POST /api/auth/logout` | `{ok: true}`, cookie cleared | -In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). +In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log. @@ -105,6 +105,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude | `PUT /api/me/prefs` `{prefs: {: "" \| null}}` | merged (`null` deletes a key), the same shape back; `400 EBADPREFS` for a key outside the list below or a value that is not a string of at most 8 KB | | `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login | | `PUT /api/me/instructions` `{instructions: "" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below | +| `GET /api/me/quota` | server mode: `{quota: {limit_usd, period: "day"\|"5h", tz_offset, source: "default"\|"user", spend, pct, exceeded, resetsAt, since} \| null}` — the account's own [spend quota](SERVER-MODE.md#spend-quotas) now; `null` without one, and always in local mode | | `GET /api/me/pool-accounts` | server mode: `{tools: [{tool, pin, choice, accounts: [{name, kind, email, expired, quotaPercent, full}]}]}` — per tool (`claude`, `codex`, `cursor`) with at least one account the account's [pool group](SERVER-MODE.md#a-users-own-pool-account) allows; never a token. Local mode: `{tools: []}` | | `PUT /api/me/pool-accounts` `{tool, account: "" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited | | `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) | diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 7c0ab869..4c49c40f 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -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 `/user-quota/u.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 diff --git a/openapi.yaml b/openapi.yaml index 219f8252..fd0f01db 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3,7 +3,7 @@ openapi: 3.0.3 info: title: Agent Dashboard for Claude Code API - version: 2.8.29 + version: 2.8.30 description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. contact: name: Nguyễn Ngọc Trí Vĩ @@ -4657,6 +4657,73 @@ components: description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows. items: $ref: '#/components/schemas/DashboardEvent' + UserQuota: + type: object + description: 'A spend quota: `limit_usd` per `period` — `day` from the local midnight of `tz_offset` (getTimezoneOffset() minutes), or `5h`, the current UTC hour and the four before it.' + required: + - limit_usd + - period + properties: + limit_usd: + type: number + minimum: 0 + exclusiveMinimum: true + period: + type: string + enum: + - day + - 5h + tz_offset: + type: integer + minimum: -840 + maximum: 840 + source: + type: string + enum: + - default + - user + readOnly: true + UserQuotaSetting: + type: object + description: 'A user''s own quota setting: `default` (the default applies), `unlimited`, or `custom` with its own limit.' + required: + - mode + properties: + mode: + type: string + enum: + - default + - unlimited + - custom + limit_usd: + type: number + period: + type: string + enum: + - day + - 5h + tz_offset: + type: integer + UserQuotaStatus: + allOf: + - $ref: '#/components/schemas/UserQuota' + - type: object + properties: + spend: + type: number + description: USD spent in the current window + pct: + type: number + exceeded: + type: boolean + description: 'Used up: new panes are refused (EQUOTA) and prompts refused by the hooks' + resetsAt: + type: string + nullable: true + description: When it opens again + since: + type: string + description: Start of the current window AccountPin: type: object required: @@ -10975,6 +11042,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '429': + description: 'EQUOTA (server mode): the workspace owner''s spend quota is used up until it resets.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '500': description: Unexpected failure (EINTERNAL). content: @@ -14453,6 +14526,30 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /api/me/quota: + get: + tags: + - Auth + operationId: getMyQuota + summary: The account's own spend quota now (null without one, and in local mode) + responses: + '200': + description: Quota + content: + application/json: + schema: + type: object + properties: + quota: + allOf: + - $ref: '#/components/schemas/UserQuotaStatus' + nullable: true + '401': + description: EAUTH + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/me/instructions: get: tags: @@ -14966,6 +15063,16 @@ paths: type: number tokens: type: integer + quotaSetting: + $ref: '#/components/schemas/UserQuotaSetting' + quota: + allOf: + - $ref: '#/components/schemas/UserQuotaStatus' + nullable: true + quotaDefault: + allOf: + - $ref: '#/components/schemas/UserQuota' + nullable: true '403': description: EADMIN — a developer content: @@ -15303,6 +15410,8 @@ paths: type: boolean enum: - true + quota: + $ref: '#/components/schemas/UserQuotaSetting' currentPassword: type: string responses: @@ -15316,7 +15425,7 @@ paths: user: $ref: '#/components/schemas/AuthUser' '400': - description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL + description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA content: application/json: schema: @@ -15413,6 +15522,72 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /api/users/quota-default: + put: + tags: + - Auth + operationId: setDefaultUserQuota + summary: 'Admin: the default spend quota for every user without one of their own (`quota: null` removes it). A user over their quota can start no pane (429 EQUOTA) and their Claude Code/Codex prompts are refused by the hooks' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - quota + - currentPassword + properties: + quota: + allOf: + - $ref: '#/components/schemas/UserQuota' + nullable: true + currentPassword: + type: string + responses: + '200': + description: Saved + content: + application/json: + schema: + type: object + properties: + user: + $ref: '#/components/schemas/AuthUser' + quotaDefault: + allOf: + - $ref: '#/components/schemas/UserQuota' + nullable: true + '400': + description: EBADQUOTA + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: ENOUSER / ENOLANE + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: ESELF — your own role or disable + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '429': + description: ETOOMANY — 10 wrong passwords in 15 minutes + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/users/{id}/password: post: tags: diff --git a/scripts/hook-handler.js b/scripts/hook-handler.js index 46506116..1986596e 100644 --- a/scripts/hook-handler.js +++ b/scripts/hook-handler.js @@ -16,9 +16,18 @@ * for the response is what made Claude Code sit at "running hooks" for seconds * whenever a dashboard was busy, slow, or wedged. * + * The one thing it may refuse: in server mode a pane carries + * `AGENTHUB_QUOTA_FILE` (server/lib/user-quota.js), and while that file says + * its owner is over their spend quota a Claude Code or Codex + * `UserPromptSubmit` exits 2 with the reason on stderr — both CLIs then drop + * the prompt and show why. Decided from that local file alone, never by + * asking the dashboard, and fail-open: no file, an unreadable one or an + * expired `until` lets the prompt through. Antigravity never gets a refusal. + * * @author Nguyễn Ngọc Trí Vĩ */ +const fs = require("fs"); const http = require("http"); const hookType = process.argv[2] || "unknown"; @@ -44,11 +53,36 @@ function resolvePorts() { const ports = resolvePorts(); +/** The reason to refuse this prompt (spend quota used up), or null to let it through. */ +function quotaRefusal() { + const file = process.env.AGENTHUB_QUOTA_FILE; + if (!file || hookType !== "UserPromptSubmit" || tool === "antigravity") return null; + try { + if (fs.statSync(file).size > 64 * 1024) return null; + const flag = JSON.parse(fs.readFileSync(file, "utf8")); + if (flag?.blocked !== true) return null; + const until = Date.parse(flag.until); + if (Number.isFinite(until) && until <= Date.now()) return null; + return typeof flag.message === "string" && flag.message + ? flag.message + : "AgentHub: your spend quota is used up."; + } catch { + return null; + } +} + +const refusal = quotaRefusal(); + let input = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => (input += chunk)); process.stdin.on("end", () => { + if (refusal) { + // The prompt never runs, so it is not reported either. + process.stderr.write(`${refusal}\n`); + process.exit(2); + } let parsedData; try { parsedData = JSON.parse(input); diff --git a/server/__tests__/user-quota.test.js b/server/__tests__/user-quota.test.js new file mode 100644 index 00000000..e1375382 --- /dev/null +++ b/server/__tests__/user-quota.test.js @@ -0,0 +1,343 @@ +/** + * @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ĩ + */ + +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); + }); +}); diff --git a/server/db.js b/server/db.js index 1d17c820..e406a818 100644 --- a/server/db.js +++ b/server/db.js @@ -2004,6 +2004,23 @@ db.exec(` PRIMARY KEY (group_id, tool, account) ); `); +// Spend quotas (server/lib/user-quota.js): user_id 0 is the default every +// user without a row of their own gets; a row whose limit_usd is NULL means +// "no limit". No foreign key (0 is no user) — a deleted user's row goes with +// the trigger. Additive. +db.exec(` + CREATE TABLE IF NOT EXISTS user_quotas ( + user_id INTEGER PRIMARY KEY, + limit_usd REAL, + period TEXT NOT NULL DEFAULT 'day' CHECK(period IN ('day','5h')), + tz_offset INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ); + CREATE TRIGGER IF NOT EXISTS trg_user_quotas_user_gone AFTER DELETE ON users + BEGIN + DELETE FROM user_quotas WHERE user_id = OLD.id; + END; +`); // A database from before the Cursor pool: its `tool` CHECK allowed only // claude/codex. Rebuilt with every pin kept. { diff --git a/server/index.js b/server/index.js index 2e31efc8..a2cd7e19 100644 --- a/server/index.js +++ b/server/index.js @@ -872,6 +872,26 @@ function startBackgroundServices() { }, POOL_REFRESH_MS); if (poolTimer.unref) poolTimer.unref(); } + // Server mode's spend quotas (user-quota.js): who is over theirs now, the + // flag files their hooks read, the panes no hook can stop. Off with + // DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode. + const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000); + if ( + Number.isFinite(QUOTA_SWEEP_MS) && + QUOTA_SWEEP_MS > 0 && + require("./lib/auth").isServerMode() + ) { + const quotaSweep = () => { + try { + require("./lib/user-quota").sweep(); + } catch (err) { + console.warn("user quota sweep failed:", err.message); + } + }; + const quotaTimer = setInterval(quotaSweep, QUOTA_SWEEP_MS); + if (quotaTimer.unref) quotaTimer.unref(); + setImmediate(quotaSweep); + } // Another dashboard on the same database (hooks reach only one of them) // moving a lane: re-broadcast it here. One pragma per beat when nothing // changed — and only while such a dashboard runs at all: alone on its data diff --git a/server/lib/omp-share.js b/server/lib/omp-share.js index 8dd54dae..5fca2090 100644 --- a/server/lib/omp-share.js +++ b/server/lib/omp-share.js @@ -108,16 +108,29 @@ function refill(t) { while (t.standby.length < STANDBY) t.standby.push(newToken()); } -/** The gateway's view: every queued key and every key a run holds — hashes only. */ -function clientsFor(t) { +/** + * The gateway's view: every queued key and every key a run holds — hashes + * only. A run whose workspace owner is over their spend quota + * (user-quota.js) is left out until it opens again: its omp can no longer + * reach a model, while its pane and key stay. + */ +function clientsFor(t, isBlocked = quotaBlocked) { const out = {}; for (const s of t.standby) out[`standby-${sha(s).slice(0, 16)}`] = { tokenSha256: sha(s) }; for (const [id, u] of Object.entries(t.used)) { - if (u && typeof u.hash === "string") out[id] = { tokenSha256: u.hash }; + if (u && typeof u.hash === "string" && !isBlocked(id)) out[id] = { tokenSha256: u.hash }; } return out; } +function quotaBlocked(runId) { + try { + return require("./user-quota").isRunBlocked(runId); + } catch { + return false; + } +} + let lastClients = null; function save(t) { @@ -519,6 +532,7 @@ module.exports = { takeToken, pruneTokens, clientsFor, + clientsFor, readTokens, PROVIDER, TOKEN_ENV, diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index 825f2e83..752d7956 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -471,6 +471,8 @@ function spawnRun(args) { // their .mcp.json) or a member in someone else's. if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER assertCwdAccount(laneId, cwd); // ECWDACCOUNT + // Server mode: an owner over their spend quota starts nothing new (user-quota.js). + require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED // A Claude session on the vendor's own API runs on the least-loaded pool // account, if the admin added any (claude-pool.js) — or the one the @@ -684,6 +686,9 @@ function spawnRun(args) { for (const k of Object.keys(env)) delete env[k]; Object.assign(env, own); } + // Server mode: where this pane's hooks look for its owner's quota flag + // (user-quota.js) — a refused prompt is decided by the hook, locally. + Object.assign(env, require("./user-quota").flagEnv(laneOwner, member)); // `after-start` tools take no prompt on their command line: paste it once // the CLI has had `delayMs` to draw its input box. ponytail: fixed delay, diff --git a/server/lib/user-quota.js b/server/lib/user-quota.js new file mode 100644 index 00000000..e813fc1a --- /dev/null +++ b/server/lib/user-quota.js @@ -0,0 +1,467 @@ +/** + * @file user-quota.js + * @description Spend quotas per user (server mode only; local mode has one + * user and never reads any of this). An admin sets a DEFAULT quota (row + * `user_id = 0` of `user_quotas`) and may override it per user — a limit of + * their own, or none at all (`limit_usd` NULL). A quota is `limit_usd` per + * `period`: `day` (from the local midnight of `tz_offset`, the admin's + * browser's getTimezoneOffset() when they saved it) or `5h` (a sliding + * window: the current UTC hour and the four before it — spend booked in hour + * H counts until H + 5 h). Spend is the users page's own booking + * (`hourlyTokenRows` over the sessions a user OWNS through a workspace, + * priced by `calculateCost`), never a second formula. + * + * A user over their quota cannot chat on until it resets: + * - `assertSpawnAllowed` refuses every new pane (`EQUOTA`, 429) — start, + * resume and a prompt from the workspace home all pass `spawnRun`; + * - every pane carries `AGENTHUB_QUOTA_FILE` (`flagEnv`), and `sweep` writes + * that file `{blocked, until, message}` — the host's under the data dir, + * a member's (S3) in their handoff dir through `write-as` — so the hook + * handler refuses a `UserPromptSubmit` of Claude Code or Codex locally, + * without asking the dashboard (scripts/hook-handler.js; fail-open); + * - a member's omp on the shared login loses its gateway key while blocked + * (`isRunBlocked`, omp-share.js `clientsFor`); + * - a pane no hook can stop (Antigravity, Cursor, a Codex without trusted + * hooks, an omp on its own login, any other catalog tool) is ended. + * Spend lands after a turn, so a user may end one turn past the limit. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const PERIODS = ["day", "5h"]; +const DEFAULT_ID = 0; +const HOUR_MS = 3_600_000; +const WINDOW_HOURS = 5; + +const db = () => require("../db").db; +const fail = (code, message) => Object.assign(new Error(message), { code }); +const isServerMode = () => require("./auth").isServerMode(); +const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13); +const hourMs = (h) => Date.parse(`${h}:00:00.000Z`); +const round = (n) => Math.round(n * 10000) / 10000; + +// ── settings ────────────────────────────────────────────────────────────── + +function row(userId) { + return db() + .prepare("SELECT limit_usd, period, tz_offset FROM user_quotas WHERE user_id = ?") + .get(userId); +} + +const asConfig = (r, source) => + r && r.limit_usd != null + ? { source, limit_usd: r.limit_usd, period: r.period, tz_offset: r.tz_offset } + : null; + +/** The default quota, or null when there is none. */ +function getDefault() { + return asConfig(row(DEFAULT_ID), "default"); +} + +/** + * A user's own setting: `default` (no row — the default applies), + * `unlimited`, or `custom` with its limit. + */ +function getOverride(userId) { + const r = row(userId); + if (!r) return { mode: "default" }; + if (r.limit_usd == null) return { mode: "unlimited" }; + return { mode: "custom", limit_usd: r.limit_usd, period: r.period, tz_offset: r.tz_offset }; +} + +/** The quota that binds `userId` now, or null (no limit). */ +function effective(userId) { + const r = row(userId); + if (r) return asConfig(r, "user"); + return getDefault(); +} + +/** `{limit_usd, period, tz_offset}` checked; throws EBADQUOTA. */ +function validate(cfg) { + const limit = Number(cfg?.limit_usd); + if (!Number.isFinite(limit) || limit <= 0 || limit > 1_000_000) { + throw fail("EBADQUOTA", "limit_usd must be a positive number of dollars"); + } + if (!PERIODS.includes(cfg.period)) { + throw fail("EBADQUOTA", `period must be one of: ${PERIODS.join(", ")}`); + } + const tz = cfg.tz_offset == null ? 0 : cfg.tz_offset; + if (!Number.isInteger(tz) || Math.abs(tz) > 14 * 60) { + throw fail("EBADQUOTA", "tz_offset must be minutes within ±840"); + } + return { limit_usd: limit, period: cfg.period, tz_offset: tz }; +} + +function upsert(userId, limit, period, tz) { + db() + .prepare( + `INSERT INTO user_quotas (user_id, limit_usd, period, tz_offset, updated_at) + VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ON CONFLICT(user_id) DO UPDATE SET limit_usd = excluded.limit_usd, + period = excluded.period, tz_offset = excluded.tz_offset, + updated_at = excluded.updated_at` + ) + .run(userId, limit, period, tz); +} + +/** Set the default quota (`null` removes it: no limit for anyone without their own). */ +function setDefault(cfg) { + if (cfg == null) { + db().prepare("DELETE FROM user_quotas WHERE user_id = ?").run(DEFAULT_ID); + return null; + } + const v = validate(cfg); + upsert(DEFAULT_ID, v.limit_usd, v.period, v.tz_offset); + return getDefault(); +} + +/** A user's setting checked: `{mode}` or `{mode: "custom", limit_usd, period, tz_offset}`. */ +function validateUserQuota(q) { + const mode = q?.mode; + if (mode === "default" || mode === "unlimited") return { mode }; + if (mode === "custom") return { mode, ...validate(q) }; + throw fail("EBADQUOTA", "quota.mode must be one of: default, unlimited, custom"); +} + +/** A user's setting: `{mode: "default" | "unlimited" | "custom", …}`. */ +function setUserQuota(userId, q) { + if (!Number.isInteger(userId) || userId <= 0) throw fail("EBADQUOTA", "no such user"); + const v = validateUserQuota(q); + if (v.mode === "default") { + db().prepare("DELETE FROM user_quotas WHERE user_id = ?").run(userId); + } else if (v.mode === "unlimited") { + upsert(userId, null, "day", 0); + } else { + upsert(userId, v.limit_usd, v.period, v.tz_offset); + } + return getOverride(userId); +} + +/** `$5/day`, `$2.50/5h`, `no limit` — for the audit log. */ +function describe(q) { + if (!q || q.mode === "unlimited") return "no limit"; + if (q.mode === "default") return "the default"; + return `$${q.limit_usd}/${q.period}`; +} + +// ── spend ───────────────────────────────────────────────────────────────── + +/** + * The window a quota counts over at `now`: `since` (a UTC hour, + * `YYYY-MM-DDTHH`, as hourly token rows are keyed) and, for a day, when the + * next one starts. Buckets are whole UTC hours, so a half-hour zone's day + * starts up to 30 min early. + */ +function windowOf(cfg, now) { + if (cfg.period === "5h") { + return { since: hourOf(now - (WINDOW_HOURS - 1) * HOUR_MS), nextReset: null }; + } + const off = cfg.tz_offset || 0; + const local = new Date(now - off * 60_000); + local.setUTCHours(0, 0, 0, 0); + const start = local.getTime() + off * 60_000; + return { since: hourOf(start), nextReset: start + 24 * HOUR_MS }; +} + +/** + * When a 5 h quota opens again: the oldest hours leave the window first, so + * it is the end of the first hour whose leaving takes the spend under the limit. + */ +function slidingReset(byHour, spend, limit) { + let left = spend; + for (const [h, cost] of [...byHour].sort((a, b) => (a[0] < b[0] ? -1 : 1))) { + left -= cost; + if (left < limit) return hourMs(h) + WINDOW_HOURS * HOUR_MS; + } + return null; +} + +/** + * Every user's quota and spend now: Map(user id → status). Only users with a + * quota are listed. One token read for all of them. + * @returns {Map} + */ +function statusAll({ now = Date.now(), userIds = null } = {}) { + const out = new Map(); + if (!isServerMode()) return out; + const ids = + userIds || + db() + .prepare("SELECT id FROM users WHERE disabled = 0") + .all() + .map((r) => r.id); + const quotas = []; + for (const id of ids) { + const cfg = effective(id); + if (cfg) quotas.push({ id, cfg, win: windowOf(cfg, now) }); + } + if (!quotas.length) return out; + const { calculateCost, hourlyTokenRows } = require("../routes/pricing"); + const { OWNED_SESSIONS } = require("../routes/users"); + const rules = require("../db").stmts.listPricing.all(); + const oldest = quotas.reduce((a, q) => (q.win.since < a ? q.win.since : a), quotas[0].win.since); + const owner = new Map(); + for (const r of db().prepare(OWNED_SESSIONS).all()) { + if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid); + } + // A session that spent in the window was touched in it. + const rows = hourlyTokenRows( + `WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`, + [oldest] + ); + const byUser = new Map(); + for (const r of rows) { + const uid = owner.get(r.session_id); + if (uid == null) continue; + if (!byUser.has(uid)) byUser.set(uid, []); + byUser.get(uid).push(r); + } + for (const { id, cfg, win } of quotas) { + const mine = (byUser.get(id) || []).filter((r) => r.date >= win.since); + const spend = mine.length ? calculateCost(mine, rules).total_cost : 0; + const exceeded = spend >= cfg.limit_usd; + let resetsAt = win.nextReset; + if (cfg.period === "5h") { + const hours = new Map(); + for (const r of mine) { + if (!hours.has(r.date)) hours.set(r.date, []); + hours.get(r.date).push(r); + } + const byHour = new Map( + [...hours].map(([h, list]) => [h, calculateCost(list, rules).total_cost]) + ); + resetsAt = exceeded ? slidingReset(byHour, spend, cfg.limit_usd) : null; + } + out.set(id, { + ...cfg, + spend: round(spend), + pct: round((spend / cfg.limit_usd) * 100), + exceeded, + resetsAt: resetsAt ? new Date(resetsAt).toISOString() : null, + since: `${win.since}:00:00.000Z`, + }); + } + return out; +} + +/** One user's status, or null without a quota. */ +function statusFor(userId, opts = {}) { + if (userId == null) return null; + return statusAll({ ...opts, userIds: [userId] }).get(userId) || null; +} + +/** "HH:MM" (and the date when it is not today) in the quota's own zone. */ +function localTime(iso, tzOffset, now = Date.now()) { + if (!iso) return null; + const off = (tzOffset || 0) * 60_000; + const at = new Date(Date.parse(iso) - off).toISOString(); + const today = new Date(now - off).toISOString().slice(0, 10); + const utc = -(tzOffset || 0) / 60; + const zone = `UTC${utc >= 0 ? "+" : ""}${utc}`; + return `${at.slice(0, 10) === today ? "" : `${at.slice(0, 10)} `}${at.slice(11, 16)} ${zone}`; +} + +/** The text a refused prompt or start shows. */ +function message(s, now = Date.now()) { + const per = s.period === "5h" ? "5 hours" : "day"; + const when = localTime(s.resetsAt, s.tz_offset, now); + return ( + `AgentHub: your spend quota is used up — $${s.spend.toFixed(2)} of $${s.limit_usd.toFixed(2)} per ${per}.` + + (when ? ` It opens again at ${when}.` : "") + + " Ask an admin to raise it if you need more." + ); +} + +/** Throws EQUOTA when the workspace owner is over their quota. No-op in local mode. */ +function assertSpawnAllowed(ownerId) { + if (!isServerMode() || ownerId == null) return; + const s = statusFor(ownerId); + if (s?.exceeded) throw Object.assign(fail("EQUOTA", message(s)), { quota: s }); +} + +// ── the flag file the hook handler reads ───────────────────────────────── + +const FLAG = "quota.json"; +const hostDir = () => path.join(require("./claude-home").getDataDir(), "user-quota"); +const hostFlag = (userId) => path.join(hostDir(), `u${userId}.json`); + +/** + * The env a pane of `ownerId` gets: where its hooks look for the flag — + * the member's handoff dir (S3: the pane runs as them) or the host's data dir. + */ +function flagEnv(ownerId, member) { + if (!isServerMode() || ownerId == null) return {}; + const file = member ? path.join(require("./run-as").handoffDir(member), FLAG) : hostFlag(ownerId); + return { AGENTHUB_QUOTA_FILE: file }; +} + +function writeHostFlag(userId, text) { + fs.mkdirSync(hostDir(), { recursive: true, mode: 0o700 }); + const file = hostFlag(userId); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, text, { mode: 0o600 }); + fs.renameSync(tmp, file); +} + +/** Write a user's flag everywhere their panes may read it. Best effort per place. */ +function writeFlags(user, s, now) { + const body = s?.exceeded + ? { blocked: true, until: s.resetsAt, message: message(s, now) } + : { blocked: false }; + const text = `${JSON.stringify(body)}\n`; + try { + writeHostFlag(user.id, text); + } catch (err) { + console.warn(`user quota: flag for ${user.username} not written:`, err.message); + } + let acct = null; + try { + acct = require("./run-as").accountForUser(user.username); + } catch { + acct = null; + } + if (!acct) return; + try { + require("./run-as").handoffWrite(acct, FLAG, text); + } catch (err) { + console.warn(`user quota: ${user.username}'s flag not written:`, err.message); + } +} + +// ── the sweep ──────────────────────────────────────────────────────────── + +/** Users over their quota now (by the last sweep). */ +let blocked = new Set(); +/** user id → the flag text last written, so an unchanged state writes nothing. */ +const written = new Map(); + +function laneOwner(laneId) { + return db().prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId)?.owner_id ?? null; +} + +/** Is run `runId`'s workspace owner over quota (by the last sweep)? omp-share asks. */ +function isRunBlocked(runId) { + if (!blocked.size) return false; + const laneId = require("./pty-run").laneIdFromRunId(runId); + return laneId != null && blocked.has(laneOwner(laneId)); +} + +/** + * Can a hook refuse this pane's next prompt? Claude Code always (the plugin's + * hooks, a member's managed-settings hooks); Codex only on the host's own + * CODEX_HOME with our UserPromptSubmit hook installed and trusted (members + * get no Codex hooks); a member's omp on the shared login through its + * gateway key. Everything else must be ended. + */ +function hookCanStop(run, member) { + if (run.tool === "claude" || run.tool === "shell") return true; + if (run.tool === "codex" && !member) { + try { + const ev = require("./codex-hooks").status().events?.UserPromptSubmit; + return !!(ev?.installed && ev?.trusted); + } catch { + return false; + } + } + if (run.tool === "omp" && member) { + try { + const share = require("./omp-share"); + return share.available() && share.enabled(); + } catch { + return false; + } + } + return false; +} + +/** End the live panes of `userIds` that no hook can stop. Returns the run ids ended. */ +function endUnstoppable(userIds) { + if (!userIds.size) return []; + const runs = require("./pty-run"); + const runAs = require("./run-as"); + const ended = []; + for (const run of runs.listRuns()) { + if (run.status !== "running" || !userIds.has(laneOwner(run.laneId))) continue; + if (hookCanStop(run, runAs.accountForLane(run.laneId))) continue; + try { + if (runs.killRun(run.id)) ended.push(run.id); + } catch (err) { + console.warn(`user quota: ${run.id} not ended:`, err.message); + } + } + return ended; +} + +/** + * Recompute every user's state, rewrite the flags that changed, end the + * panes no hook can stop and re-list the omp gateway's keys. Called every + * minute by the server and right after an admin changes a quota. + */ +function sweep({ now = Date.now() } = {}) { + if (!isServerMode()) return { blocked: [] }; + const users = db().prepare("SELECT id, username FROM users").all(); + const status = statusAll({ now }); + const next = new Set(); + for (const u of users) { + const s = status.get(u.id) || null; + if (s?.exceeded) next.add(u.id); + const text = s?.exceeded ? `${s.resetsAt}|${s.limit_usd}` : "open"; + // A user never blocked has no flag to clear: nothing is written for them. + if (written.get(u.id) === text || (!written.has(u.id) && !s?.exceeded && !hadFlag(u.id))) { + continue; + } + writeFlags(u, s, now); + written.set(u.id, text); + } + const changed = next.size !== blocked.size || [...next].some((id) => !blocked.has(id)); + blocked = next; + const ended = endUnstoppable(next); + if (changed) { + try { + require("./omp-share").syncClients(); + } catch { + /* sharing off or not available */ + } + } + return { blocked: [...next], ended }; +} + +/** A flag left by an earlier server (it may still say blocked). */ +function hadFlag(userId) { + try { + return fs.existsSync(hostFlag(userId)); + } catch { + return false; + } +} + +/** Test seam: forget what the sweep remembered. */ +function __reset() { + blocked = new Set(); + written.clear(); +} + +module.exports = { + PERIODS, + getDefault, + getOverride, + effective, + setDefault, + setUserQuota, + validateUserQuota, + describe, + windowOf, + slidingReset, + statusAll, + statusFor, + message, + assertSpawnAllowed, + flagEnv, + isRunBlocked, + hookCanStop, + sweep, + __reset, +}; diff --git a/server/openapi-extra/auth.js b/server/openapi-extra/auth.js index bf9a5b0f..60007fdb 100644 --- a/server/openapi-extra/auth.js +++ b/server/openapi-extra/auth.js @@ -19,7 +19,8 @@ * (Auth) — Settings → Account: the account's own profile, password and logins * - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture * - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to - * - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form + * - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form (its `quota` too) + * - PUT /api/users/quota-default, GET /api/me/quota (Auth) — spend quotas per user * - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst) * - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle * (Auth) — each member's own tmux server and an admin's restart of it (S3) @@ -37,6 +38,48 @@ const tags = [ ]; const schemas = { + UserQuota: { + type: "object", + description: + "A spend quota: `limit_usd` per `period` — `day` from the local midnight of `tz_offset` (getTimezoneOffset() minutes), or `5h`, the current UTC hour and the four before it.", + required: ["limit_usd", "period"], + properties: { + limit_usd: { type: "number", minimum: 0, exclusiveMinimum: true }, + period: { type: "string", enum: ["day", "5h"] }, + tz_offset: { type: "integer", minimum: -840, maximum: 840 }, + source: { type: "string", enum: ["default", "user"], readOnly: true }, + }, + }, + UserQuotaSetting: { + type: "object", + description: + "A user's own quota setting: `default` (the default applies), `unlimited`, or `custom` with its own limit.", + required: ["mode"], + properties: { + mode: { type: "string", enum: ["default", "unlimited", "custom"] }, + limit_usd: { type: "number" }, + period: { type: "string", enum: ["day", "5h"] }, + tz_offset: { type: "integer" }, + }, + }, + UserQuotaStatus: { + allOf: [ + { $ref: "#/components/schemas/UserQuota" }, + { + type: "object", + properties: { + spend: { type: "number", description: "USD spent in the current window" }, + pct: { type: "number" }, + exceeded: { + type: "boolean", + description: "Used up: new panes are refused (EQUOTA) and prompts refused by the hooks", + }, + resetsAt: { type: "string", nullable: true, description: "When it opens again" }, + since: { type: "string", description: "Start of the current window" }, + }, + }, + ], + }, AccountPin: { type: "object", required: ["userId", "username", "account"], @@ -214,6 +257,32 @@ const paths = { }, }, }, + "/api/me/quota": { + get: { + tags: ["Auth"], + operationId: "getMyQuota", + summary: "The account's own spend quota now (null without one, and in local mode)", + responses: { + 200: { + description: "Quota", + content: { + "application/json": { + schema: { + type: "object", + properties: { + quota: { + allOf: [{ $ref: "#/components/schemas/UserQuotaStatus" }], + nullable: true, + }, + }, + }, + }, + }, + }, + 401: err("EAUTH"), + }, + }, + }, "/api/me/instructions": { get: { tags: ["Auth"], @@ -504,11 +573,20 @@ const paths = { type: "object", properties: { cost: { type: "number" }, tokens: { type: "integer" } }, }, + quotaSetting: { $ref: "#/components/schemas/UserQuotaSetting" }, + quota: { + allOf: [{ $ref: "#/components/schemas/UserQuotaStatus" }], + nullable: true, + }, }, }, ], }, }, + quotaDefault: { + allOf: [{ $ref: "#/components/schemas/UserQuota" }], + nullable: true, + }, }, }, }, @@ -712,10 +790,11 @@ const paths = { addWorkspaces: { type: "array", items: { type: "integer" } }, removeWorkspaces: { type: "array", items: { type: "integer" } }, signOut: { type: "boolean", enum: [true] }, + quota: { $ref: "#/components/schemas/UserQuotaSetting" }, }), responses: { ...changeResponses, - 400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL"), + 400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL / EBADQUOTA"), 409: err("ESELF / ENOTOWNED / EOWNERFOLDER"), }, }, @@ -734,6 +813,44 @@ const paths = { }, }, }, + "/api/users/quota-default": { + put: { + tags: ["Auth"], + operationId: "setDefaultUserQuota", + summary: + "Admin: the default spend quota for every user without one of their own (`quota: null` removes it). A user over their quota can start no pane (429 EQUOTA) and their Claude Code/Codex prompts are refused by the hooks", + requestBody: changeBody( + { + quota: { + allOf: [{ $ref: "#/components/schemas/UserQuota" }], + nullable: true, + }, + }, + ["quota"] + ), + responses: { + ...changeResponses, + 200: { + description: "Saved", + content: { + "application/json": { + schema: { + type: "object", + properties: { + user: { $ref: "#/components/schemas/AuthUser" }, + quotaDefault: { + allOf: [{ $ref: "#/components/schemas/UserQuota" }], + nullable: true, + }, + }, + }, + }, + }, + }, + 400: err("EBADQUOTA"), + }, + }, + }, "/api/users/{id}/password": { post: { tags: ["Auth"], diff --git a/server/openapi-extra/run.js b/server/openapi-extra/run.js index 49347082..ebfccecb 100644 --- a/server/openapi-extra/run.js +++ b/server/openapi-extra/run.js @@ -411,6 +411,9 @@ const paths = { 409: errorJson( "Conflict with live state: ERUNLIVE (the pane is busy), ENOTSHARED, EMEMBERGONE, EOWNERFOLDER or ECWDACCOUNT." ), + 429: errorJson( + "EQUOTA (server mode): the workspace owner's spend quota is used up until it resets." + ), 503: errorJson("tmux is not installed (ETMUXMISSING)."), 500: errorJson("Unexpected failure (EINTERNAL)."), }, diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 505891c0..1927ff20 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -3041,11 +3041,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { // A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool // cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault. // ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state. + // EQUOTA (server mode): the workspace owner is over their spend quota. const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "") ? 400 : ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code) ? 409 - : 500; + : err.code === "EQUOTA" + ? 429 + : 500; return res.status(status).json({ error: { code: err.code, message: err.message } }); } diff --git a/server/routes/me.js b/server/routes/me.js index 1648d181..c5672d34 100644 --- a/server/routes/me.js +++ b/server/routes/me.js @@ -36,6 +36,8 @@ * 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. + * `/quota` is the account's own spend quota now (server/lib/user-quota.js — + * `null` without one, and in local mode), for the status bar. * * 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. @@ -170,6 +172,16 @@ router.put("/instructions", sameOriginGuard, (req, res) => { res.json(instructionsView(key)); }); +// The account's own spend quota now (server/lib/user-quota.js): `{quota}` — +// limit, period, spend, whether it is used up and when it opens again — or +// `{quota: null}` with no limit, and always in local mode. +router.get("/quota", (req, res) => { + const key = userKey(req); + if (!key) return unauth(res); + const id = auth.isServerMode() ? (req.user || auth.userFromRequest(req))?.id : null; + res.json({ quota: id == null ? null : require("../lib/user-quota").statusFor(id) }); +}); + router.post("/onboarded", sameOriginGuard, (req, res) => { const key = userKey(req); if (!key) return unauth(res); diff --git a/server/routes/run.js b/server/routes/run.js index 0b8fc277..aa772dc8 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -672,6 +672,10 @@ router.post("/", (req, res) => { ) { return res.status(409).json({ error: { code: err.code, message: err.message } }); } + // Server mode: the workspace owner is over their spend quota (user-quota.js). + if (err.code === "EQUOTA") { + return res.status(429).json({ error: { code: err.code, message: err.message } }); + } // Nor is a host missing tmux: the request was fine, the dependency is not // installed. 400 would tell the user to fix their input. if (err.code === "ETMUXMISSING") { diff --git a/server/routes/users.js b/server/routes/users.js index 4d9fc1c8..a57e0244 100644 --- a/server/routes/users.js +++ b/server/routes/users.js @@ -37,6 +37,12 @@ * non-empty and makes that account pick its own at its next login * (`must_change_password`); an admin's own new password must pass every * rule, like Settings → Account's (`validateOwnPassword`, EWEAKPASSWORD). + * + * Spend quotas (server/lib/user-quota.js): `GET /` also gives each account + * its own setting (`quotaSetting`) and the quota binding it now with its spend + * (`quota`), plus the `quotaDefault`; `PUT /quota-default` sets or clears the + * default and `PATCH /:id` `{quota}` a user's own (`default`, `unlimited` or + * `custom`) — account changes like the rest, and each re-sweeps at once. * @author Nguyễn Ngọc Trí Vĩ */ @@ -130,6 +136,8 @@ router.get("/", (req, res) => { rules ); const none = { cost: 0, tokens: 0 }; + const quota = require("../lib/user-quota"); + const quotas = quota.statusAll(); const users = listUsers().map((u) => ({ ...u, ...(logins.get(u.id) || { logins: 0, lastLoginAt: null }), @@ -138,8 +146,12 @@ router.get("/", (req, res) => { today: today.get(u.id) || none, last7d: last7d.get(u.id) || none, last30d: last30d.get(u.id) || none, + // Spend quota (user-quota.js): this user's own setting, and the quota that + // binds them now with its spend (null: no limit). + quotaSetting: quota.getOverride(u.id), + quota: quotas.get(u.id) || null, })); - res.json({ users }); + res.json({ users, quotaDefault: quota.getDefault() }); }); router.get("/storage", (req, res) => { @@ -164,6 +176,7 @@ const STATUS = { EWEAKPASSWORD: 400, EBADROLE: 400, EBADFIELD: 400, + EBADQUOTA: 400, EREAUTH: 403, ELOCALMODE: 403, ENOUSER: 404, @@ -196,7 +209,9 @@ function accountChange(handler) { await auth.confirmPassword(req.user, body.currentPassword); const out = await handler(req, body); recordAccountChange(req.user.id, out.user.id, req.method, out.action); - res.status(out.status || 200).json({ user: out.user }); + res + .status(out.status || 200) + .json({ user: out.user, ...("quotaDefault" in out && { quotaDefault: out.quotaDefault }) }); } catch (err) { const status = STATUS[err.code]; if (!status) return res.status(500).json({ error: { message: err.message } }); @@ -507,6 +522,37 @@ router.post( }) ); +/** + * The default spend quota every user without one of their own gets + * (server/lib/user-quota.js): `{quota: {limit_usd, period, tz_offset}}`, or + * `{quota: null}` for none. The admin's password again, audited. + */ +router.put( + "/quota-default", + accountChange(async (req, body) => { + const quota = require("../lib/user-quota"); + if (body.quota === undefined) { + throw Object.assign(new Error("quota: an object or null"), { code: "EBADQUOTA" }); + } + const saved = quota.setDefault(body.quota); + quotaSweep(); + return { + user: auth.publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.user.id)), + action: `default spend quota: ${saved ? `$${saved.limit_usd}/${saved.period}` : "none"}`, + quotaDefault: saved, + }; + }) +); + +/** Apply a quota change now rather than at the next minute's sweep. */ +function quotaSweep() { + try { + require("../lib/user-quota").sweep(); + } catch (err) { + console.warn("user quota sweep failed:", err.message); + } +} + router.patch( "/:id", accountChange(async (req, body) => { @@ -542,6 +588,8 @@ router.patch( if (body.signOut !== undefined && body.signOut !== true) { throw Object.assign(new Error("signOut: true"), { code: "EBADFIELD" }); } + const quotaLib = require("../lib/user-quota"); + const quota = body.quota === undefined ? null : quotaLib.validateUserQuota(body.quota); const add = laneIds(body.addWorkspaces, "addWorkspaces"); const remove = laneIds(body.removeWorkspaces, "removeWorkspaces"); const runAs = require("../lib/run-as"); @@ -584,6 +632,11 @@ router.patch( auth.endSessions(u.id, keepToken); done.push("signed out everywhere"); } + if (quota) { + quotaLib.setUserQuota(u.id, quota); + done.push(`spend quota ${quotaLib.describe(quota)}`); + quotaSweep(); + } if (disabledChange) { auth.setDisabled(u.username, body.disabled); done.push(body.disabled ? "disable" : "enable"); @@ -710,3 +763,5 @@ router.post( ); module.exports = router; +// Which account each session belongs to, for the spend quotas (server/lib/user-quota.js). +module.exports.OWNED_SESSIONS = OWNED_SESSIONS;