84e2a9f0ac
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
678 lines
26 KiB
JavaScript
678 lines
26 KiB
JavaScript
/**
|
|
* @file quota.js
|
|
* @description How much of each native AI tool's plan quota is spent, for the
|
|
* status bar's quota chips (`GET /api/pricing/quota`).
|
|
*
|
|
* - **Claude**: the logged-in account's `GET /api/oauth/usage` (`cc-usage.js`),
|
|
* with the access token the CLI holds RIGHT NOW. It is never refreshed here —
|
|
* a refresh rotates the CLI's credentials — so an expired token answers
|
|
* `ELIVEEXPIRED` until Claude Code next refreshes it itself. Every OTHER
|
|
* saved auth profile (`cc-profiles.js`) is asked with its own stored token
|
|
* while that token is valid and never refreshed (a refresh rotates the
|
|
* saved credentials); past expiry it shows its last figure, `asOf`. Cached
|
|
* `CLAUDE_TTL_MS` per account, so a poll from every open tab is one
|
|
* upstream call each. The live entry is `active: true`. Each token's owner
|
|
* is asked too (`fetchOwner`): the live row is named after the account its
|
|
* token belongs to (`.claude.json` can name another), and a saved profile
|
|
* whose token belongs to a different account answers `EWRONGACCOUNT`,
|
|
* never that account's usage.
|
|
* - **Codex**: no network. Every `token_count` event Codex writes into its
|
|
* rollout journal carries the account's `rate_limits`; the newest one wins.
|
|
* - **Cursor**: the Cursor CLI's (`cursor-agent`) own login —
|
|
* `$XDG_CONFIG_HOME/cursor/auth.json` — sent ONLY to cursor.com's
|
|
* `GET /api/usage-summary` (undocumented; what cursor.com/dashboard reads),
|
|
* as the `WorkosCursorSessionToken=<user id>::<token>` cookie it expects,
|
|
* while the token is valid and never refreshed. One `mo` window: the
|
|
* billing cycle's `totalPercentUsed`. No file (macOS keeps it in the
|
|
* keychain) means no row.
|
|
* - **Antigravity**: `agy --print /usage --output-format json` — the CLI's
|
|
* own `/usage` command (no model call: `num_turns` 0, no conversation),
|
|
* run by the catalog-detected binary in a private empty directory, only
|
|
* when the tool is enabled and `agy --version` is at least 1.1.11 (an
|
|
* older agy may bill `/usage` as a model request). One window per pool
|
|
* bucket, `group` naming the pool (Gemini / Claude and GPT). Its report
|
|
* names no account and no plan. Cached like Claude; a failed run shows
|
|
* the last figure with its `asOf`. Running agy may let it update itself;
|
|
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
|
* - **Grok Build**: the CLI's `auth.x.ai` session in `$GROK_HOME/auth.json`
|
|
* (default `~/.grok`), sent only to `cli-chat-proxy.grok.com`'s
|
|
* `GET /v1/billing?format=credits`: `wk` from `creditUsagePercent`, `mo`
|
|
* from `used / monthlyLimit`, plan `subscriptionTier`.
|
|
* - **Kimi Code**: `$KIMI_CODE_HOME/credentials/kimi-code.json` (default
|
|
* `~/.kimi-code`), sent only to `api.kimi.com`'s `GET /coding/v1/usages`:
|
|
* `5h`, `wk`, `mo` from `limit_5h`/`limit_7d`/`limit_month_total`.
|
|
* - **OpenCode Go**: opencode's own `opencode-go` API key — its `auth.json`
|
|
* (OpenCode 1) else its `opencode.db` `credential` table (OpenCode 2),
|
|
* under `$XDG_DATA_HOME/opencode` (default `~/.local/share`) — sent only to
|
|
* `opencode.ai`'s `GET /zen/go/v1/usage`: `5h`, `wk`, `mo` meters. No Go
|
|
* key (another provider's login) means no row.
|
|
* Grok's and Kimi's tokens are used only while valid and never refreshed
|
|
* (Kimi rotates its refresh token: a refresh here would sign the CLI out).
|
|
* These three are Orca's readers (github.com/stablyai/orca, MIT), trimmed.
|
|
*
|
|
* Each tool also carries its signed-in `account` (an email), its `plan` (the
|
|
* subscription's name as the tool reports it: Claude's `subscriptionType`,
|
|
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
|
|
* `expiresAt`, when its stored access token runs out — Claude's
|
|
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
|
|
* read, never verified).
|
|
*
|
|
* Server mode with S3 (`quotas({home})`): a member's plan is read from THEIR
|
|
* Linux account's home only — `.claude`, `.codex`, `.config/cursor` +
|
|
* `.cursor`, `.grok`, `.kimi-code`, `.local/share/opencode`, never the
|
|
* host's env overrides — through `run-as`'s owner-checked reads (F3). No
|
|
* saved Claude profiles (the Profiles modal is the host's) and no agy: its
|
|
* `/usage` needs the self-updating binary, so it stays host-only.
|
|
*
|
|
* Only percentages, window lengths, reset/expiry times, the plan's name and
|
|
* the account's email leave this module — never a token.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { execFile } = require("node:child_process");
|
|
const { promisify } = require("node:util");
|
|
const aiTools = require("./ai-tools");
|
|
const { getCodexHome } = require("./codex-home");
|
|
const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
|
|
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
|
const { allRolloutFiles } = require("./codex-ingest");
|
|
const { getCursorDir } = require("./cursor-usage");
|
|
const { Database } = require("../db");
|
|
const { readFileSafe, openSafe, ownerForRead } = require("./run-as");
|
|
const { s3Enabled } = require("./linux-accounts");
|
|
|
|
const CLAUDE_TTL_MS = 5 * 60_000;
|
|
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
|
|
const EXPIRY_MARGIN_MS = 60_000;
|
|
const TAIL_BYTES = 512 * 1024;
|
|
const CODEX_FILES = 5;
|
|
const CURSOR_USAGE_URL = "https://cursor.com/api/usage-summary";
|
|
const TIMEOUT_MS = 10_000;
|
|
const GROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
|
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
|
const OPENCODE_GO_URL = "https://opencode.ai/zen/go/v1/usage";
|
|
const AGY_TIMEOUT_MS = 60_000;
|
|
const AGY_MIN_VERSION = [1, 1, 11];
|
|
const AGY_WINDOW = { "5h": "5h", weekly: "wk" };
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const cache = new Map(); // "live" | "profile:<name>" → { at, value }
|
|
const lastGood = new Map(); // profile name → { windows, at }
|
|
|
|
/** One upstream call per key per `CLAUDE_TTL_MS` (`FORCE_MIN_MS` on a click);
|
|
* a call still in flight is shared, so two open tabs never run it twice. */
|
|
async function cached(key, force, fn) {
|
|
const hit = cache.get(key);
|
|
const age = hit ? Date.now() - hit.at : Infinity;
|
|
if (age < (force ? FORCE_MIN_MS : CLAUDE_TTL_MS)) return hit.value;
|
|
const value = fn();
|
|
cache.set(key, { at: Date.now(), value });
|
|
return value;
|
|
}
|
|
|
|
function windowLabel(minutes) {
|
|
if (minutes === 300) return "5h";
|
|
if (minutes === 10080) return "wk";
|
|
if (minutes >= 40000 && minutes <= 45000) return "mo";
|
|
return minutes % 1440 === 0 ? `${minutes / 1440}d` : `${Math.round(minutes / 60)}h`;
|
|
}
|
|
|
|
const iso = (ms) => (Number(ms) ? new Date(Number(ms)).toISOString() : null);
|
|
const fresh = (ms) => Number(ms) > Date.now() + EXPIRY_MARGIN_MS;
|
|
|
|
function pct(w) {
|
|
return { usedPercent: w.utilization, resetsAt: w.resetsAt };
|
|
}
|
|
|
|
function claudeWindows(u) {
|
|
const windows = [];
|
|
if (u.fiveHour) windows.push({ label: "5h", ...pct(u.fiveHour) });
|
|
if (u.sevenDay) windows.push({ label: "wk", ...pct(u.sevenDay) });
|
|
return windows;
|
|
}
|
|
|
|
/** A token's windows — unless it belongs to another account than `uuid`. */
|
|
async function usageFor(token, uuid) {
|
|
const [usage, owner] = await Promise.all([fetchUsage(token), fetchOwner(token)]);
|
|
if (uuid && owner && owner.uuid !== uuid) {
|
|
throw Object.assign(new Error("this sign-in belongs to another account"), {
|
|
code: "EWRONGACCOUNT",
|
|
});
|
|
}
|
|
return claudeWindows(usage);
|
|
}
|
|
|
|
/** A member's Claude login: `<home>/.claude/.credentials.json` + `.claude.json`. */
|
|
function memberClaudeAuth(home) {
|
|
const creds = readJson(path.join(home, ".claude", ".credentials.json"));
|
|
const cfg = [path.join(home, ".claude.json"), path.join(home, ".claude", ".claude.json")]
|
|
.map(readJson)
|
|
.find((c) => c?.oauthAccount);
|
|
return { oauth: creds?.claudeAiOauth || null, account: cfg?.oauthAccount || null };
|
|
}
|
|
|
|
/** The account the CLI is logged into, with the token it holds right now. */
|
|
async function liveClaude(readAuth = readActiveAuth) {
|
|
let who = {};
|
|
try {
|
|
const { oauth, account } = readAuth();
|
|
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
|
|
who = {
|
|
account: account?.emailAddress || account?.displayName || null,
|
|
plan: oauth.subscriptionType || null,
|
|
expiresAt: iso(oauth.expiresAt),
|
|
};
|
|
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
|
// The token's owner, not `.claude.json`, is who this usage is billed to.
|
|
const [usage, owner] = await Promise.all([
|
|
fetchUsage(oauth.accessToken),
|
|
fetchOwner(oauth.accessToken),
|
|
]);
|
|
return {
|
|
...who,
|
|
account: owner?.email || who.account,
|
|
ownerUuid: owner?.uuid || account?.accountUuid || null,
|
|
windows: claudeWindows(usage),
|
|
};
|
|
} catch (e) {
|
|
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A saved profile that is not the live account, asked with its OWN stored
|
|
* token only while that token is still valid. Never refreshed here: a refresh
|
|
* rotates the profile's credentials on disk, so it happens only on the
|
|
* Profiles modal's Check quota click (`cc-usage.profileUsage`). Past expiry
|
|
* the last figure this process read is shown with its `asOf`.
|
|
*/
|
|
async function profileClaude(item) {
|
|
const who = {
|
|
account: item.email || item.displayName || null,
|
|
plan: item.subscriptionType || null,
|
|
expiresAt: iso(item.expiresAt),
|
|
};
|
|
let creds = null;
|
|
try {
|
|
creds = readProfile(item.name)?.credentials || null;
|
|
} catch {
|
|
/* a corrupt profile file reads as signed out */
|
|
}
|
|
if (creds?.accessToken && fresh(creds.expiresAt)) {
|
|
try {
|
|
const windows = await usageFor(creds.accessToken, item.accountUuid);
|
|
lastGood.set(item.name, { windows, at: Date.now() });
|
|
return { ...who, windows };
|
|
} catch (e) {
|
|
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
|
}
|
|
}
|
|
const prev = lastGood.get(item.name);
|
|
if (!prev) return { ...who, windows: [], error: "EPROFILEEXPIRED" };
|
|
return { ...who, windows: prev.windows.map(expireReset), asOf: iso(prev.at) };
|
|
}
|
|
|
|
/** A window whose reset has passed is empty again. */
|
|
function expireReset(w) {
|
|
return w.resetsAt && Date.parse(w.resetsAt) < Date.now() ? { ...w, usedPercent: 0 } : w;
|
|
}
|
|
|
|
/** The live account first, then every other saved profile (by label). */
|
|
async function claudeQuotas(force, home) {
|
|
if (home) {
|
|
const live = await cached(`${home}|live`, force, () =>
|
|
liveClaude(() => memberClaudeAuth(home))
|
|
);
|
|
if (!live) return [];
|
|
const { ownerUuid: _uuid, ...shown } = live;
|
|
return [{ id: "claude", active: true, label: null, ...shown }];
|
|
}
|
|
let items = [];
|
|
try {
|
|
items = listProfiles().items;
|
|
} catch {
|
|
/* no profiles dir */
|
|
}
|
|
const live = await cached("live", force, liveClaude);
|
|
// The live row is the profile of whoever the token belongs to.
|
|
const mine = live?.ownerUuid
|
|
? items.find((it) => it.accountUuid === live.ownerUuid)
|
|
: items.find((it) => it.active);
|
|
const others = await Promise.all(
|
|
items
|
|
.filter((it) => it !== mine)
|
|
.map(async (it) => ({
|
|
id: "claude",
|
|
active: false,
|
|
label: it.label,
|
|
...(await cached(`profile:${it.name}`, force, () => profileClaude(it))),
|
|
}))
|
|
);
|
|
if (!live) return others;
|
|
const { ownerUuid: _uuid, ...shown } = live;
|
|
return [{ id: "claude", active: true, label: mine?.label || null, ...shown }, ...others];
|
|
}
|
|
|
|
/** The newest `rate_limits` in the last `TAIL_BYTES` of a rollout, or null. */
|
|
function lastRateLimits(file) {
|
|
let text;
|
|
try {
|
|
const fd = openSafe(file.path);
|
|
try {
|
|
const len = Math.min(file.size, TAIL_BYTES);
|
|
const buf = Buffer.alloc(len);
|
|
fs.readSync(fd, buf, 0, len, file.size - len);
|
|
text = buf.toString("utf8");
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
const lines = text.split("\n");
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
if (!lines[i].includes('"rate_limits"')) continue;
|
|
try {
|
|
const rl = JSON.parse(lines[i])?.payload?.rate_limits;
|
|
if (rl && (rl.primary || rl.secondary)) return { rl, mtime: file.mtime };
|
|
} catch {
|
|
/* a cut first line or a partial write */
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function codexWindow(w, mtime) {
|
|
if (!w || typeof w.used_percent !== "number") return null;
|
|
const resetsMs = Number(w.resets_at)
|
|
? Number(w.resets_at) * 1000
|
|
: Number(w.resets_in_seconds)
|
|
? mtime + Number(w.resets_in_seconds) * 1000
|
|
: null;
|
|
// A window that has reset since Codex last wrote is empty again.
|
|
const used = resetsMs && resetsMs < Date.now() ? 0 : w.used_percent;
|
|
return {
|
|
label: windowLabel(Number(w.window_minutes)),
|
|
usedPercent: used,
|
|
resetsAt: resetsMs ? new Date(resetsMs).toISOString() : null,
|
|
};
|
|
}
|
|
|
|
/** A JWT's claims, unverified — only for showing who is signed in. */
|
|
function jwtClaims(token) {
|
|
return JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString("utf8"));
|
|
}
|
|
|
|
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
|
|
function codexAccount(codexHome = getCodexHome()) {
|
|
try {
|
|
const { tokens } = JSON.parse(readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 }));
|
|
const { exp } = jwtClaims(tokens.access_token);
|
|
const id = jwtClaims(tokens.id_token);
|
|
return {
|
|
account: id.email || null,
|
|
plan: id["https://api.openai.com/auth"]?.chatgpt_plan_type || null,
|
|
expiresAt: exp ? new Date(exp * 1000).toISOString() : null,
|
|
};
|
|
} catch {
|
|
return {}; // API-key login, no file, or a token that is not a JWT
|
|
}
|
|
}
|
|
|
|
function codexQuota(codexHome = getCodexHome()) {
|
|
const files = allRolloutFiles(codexHome)
|
|
.sort((a, b) => b.mtime - a.mtime)
|
|
.slice(0, CODEX_FILES);
|
|
for (const f of files) {
|
|
const found = lastRateLimits(f);
|
|
if (!found) continue;
|
|
const windows = [found.rl.primary, found.rl.secondary]
|
|
.map((w) => codexWindow(w, found.mtime))
|
|
.filter(Boolean);
|
|
return {
|
|
id: "codex",
|
|
active: true,
|
|
...codexAccount(codexHome),
|
|
windows,
|
|
asOf: new Date(found.mtime).toISOString(),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** A JSON file, or null; a member's only if it is theirs (`readFileSafe`). */
|
|
function readJson(file) {
|
|
try {
|
|
return JSON.parse(readFileSafe(file, "utf8", { max: 1 << 20 }));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Where each tool keeps its login: the host's (env overrides honoured), or a
|
|
* member's home at the tools' defaults — a member's env is not the host's.
|
|
*/
|
|
function toolDirs(home) {
|
|
const h = home || os.homedir();
|
|
const env = (k) => (home ? null : process.env[k]);
|
|
return {
|
|
codex: home ? path.join(home, ".codex") : getCodexHome(),
|
|
cursorConfig: path.join(env("XDG_CONFIG_HOME") || path.join(h, ".config"), "cursor"),
|
|
cursor: home ? path.join(home, ".cursor") : getCursorDir(),
|
|
grok: env("GROK_HOME") || path.join(h, ".grok"),
|
|
kimi: env("KIMI_CODE_HOME") || path.join(h, ".kimi-code"),
|
|
opencode: path.join(env("XDG_DATA_HOME") || path.join(h, ".local", "share"), "opencode"),
|
|
};
|
|
}
|
|
|
|
/** The Cursor CLI's login and the account's plan + billing-cycle usage. */
|
|
async function cursorQuota(dirs = toolDirs()) {
|
|
const token = readJson(path.join(dirs.cursorConfig, "auth.json"))?.accessToken;
|
|
if (typeof token !== "string" || !token) return null; // not logged in
|
|
let claims = {};
|
|
try {
|
|
claims = jwtClaims(token);
|
|
} catch {
|
|
/* not a JWT: no expiry to check, cursor.com decides */
|
|
}
|
|
const info = readJson(path.join(dirs.cursor, "cli-config.json"))?.authInfo || {};
|
|
const who = {
|
|
id: "cursor",
|
|
active: true,
|
|
account: info.email || null,
|
|
plan: null,
|
|
expiresAt: claims.exp ? new Date(claims.exp * 1000).toISOString() : null,
|
|
};
|
|
if (claims.exp && !fresh(claims.exp * 1000))
|
|
return { ...who, windows: [], error: "ETOKENEXPIRED" };
|
|
// The cookie names the user as the dashboard does: `user_…`, no `auth0|`.
|
|
const user = String(info.authId || info.userId || claims.sub || "")
|
|
.split("|")
|
|
.pop();
|
|
try {
|
|
const res = await fetch(CURSOR_USAGE_URL, {
|
|
headers: { Accept: "application/json", Cookie: `WorkosCursorSessionToken=${user}::${token}` },
|
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
});
|
|
if (!res.ok) {
|
|
return { ...who, windows: [], error: res.status === 401 ? "ETOKENEXPIRED" : "EUPSTREAM" };
|
|
}
|
|
const u = await res.json();
|
|
const p = u?.individualUsage?.plan;
|
|
// Enterprise seats answer without `plan`: the plan name, no figure.
|
|
const used = typeof p?.totalPercentUsed === "number" ? p.totalPercentUsed : null;
|
|
return {
|
|
...who,
|
|
plan: u?.membershipType || null,
|
|
windows:
|
|
used === null
|
|
? []
|
|
: [{ label: "mo", usedPercent: used, resetsAt: u.billingCycleEnd || null }],
|
|
};
|
|
} catch {
|
|
return { ...who, windows: [], error: "EUPSTREAM" };
|
|
}
|
|
}
|
|
|
|
/** `agy --version` at least `AGY_MIN_VERSION`. */
|
|
function agyVersionOk(text) {
|
|
const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
|
|
if (!m) return false;
|
|
for (let i = 0; i < 3; i++) {
|
|
const d = Number(m[i + 1]) - AGY_MIN_VERSION[i];
|
|
if (d) return d > 0;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** `/usage`'s pools → windows: 5h before the week, in the pools' order. */
|
|
function agyWindows(report) {
|
|
const windows = [];
|
|
for (const g of report?.command?.data?.groups || []) {
|
|
const rows = (g.buckets || [])
|
|
.filter((b) => AGY_WINDOW[b.window] && typeof b.remaining_fraction === "number")
|
|
.map((b) => {
|
|
const resets = Date.parse(b.reset_time);
|
|
return {
|
|
group: String(g.name || ""),
|
|
label: AGY_WINDOW[b.window],
|
|
// A window past its reset is full again, as Codex's is.
|
|
usedPercent: resets < Date.now() ? 0 : (1 - b.remaining_fraction) * 100,
|
|
resetsAt: Number.isFinite(resets) ? new Date(resets).toISOString() : null,
|
|
};
|
|
});
|
|
rows.sort((a, b) => (a.label === "5h" ? -1 : 0) - (b.label === "5h" ? -1 : 0));
|
|
windows.push(...rows);
|
|
}
|
|
return windows;
|
|
}
|
|
|
|
async function antigravityQuota() {
|
|
if (process.env.AGENTHUB_AGY_USAGE === "0" || !aiTools.isEnabled("antigravity")) return null;
|
|
const { installed, path: bin } = aiTools.detect("antigravity");
|
|
if (!installed || !bin) return null;
|
|
const run = (args, cwd) =>
|
|
execFileAsync(bin, args, { cwd, timeout: AGY_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-usage-"));
|
|
try {
|
|
if (!agyVersionOk((await run(["--version"], dir)).stdout)) return null;
|
|
const { stdout } = await run(["--print", "/usage", "--output-format", "json"], dir);
|
|
const windows = agyWindows(JSON.parse(stdout));
|
|
if (!windows.length) throw new Error("no quota in /usage");
|
|
lastGood.set("antigravity", { windows, at: Date.now() });
|
|
return { id: "antigravity", active: true, windows };
|
|
} catch {
|
|
const prev = lastGood.get("antigravity");
|
|
if (!prev) return { id: "antigravity", active: true, windows: [], error: "EUPSTREAM" };
|
|
return {
|
|
id: "antigravity",
|
|
active: true,
|
|
windows: prev.windows.map(expireReset),
|
|
asOf: iso(prev.at),
|
|
};
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const isoOf = (v) => {
|
|
const ms = Date.parse(v);
|
|
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
};
|
|
|
|
/** A Bearer GET to a tool's own host: its windows, or the row's error. */
|
|
async function bearerWindows(who, url, token, headers, toWindows) {
|
|
try {
|
|
const res = await fetch(url, {
|
|
headers: { Accept: "application/json", Authorization: `Bearer ${token}`, ...headers },
|
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
});
|
|
if (!res.ok) {
|
|
const expired = res.status === 401 || res.status === 403;
|
|
return { ...who, windows: [], error: expired ? "ETOKENEXPIRED" : "EUPSTREAM" };
|
|
}
|
|
return { ...who, ...toWindows(await res.json()) };
|
|
} catch {
|
|
return { ...who, windows: [], error: "EUPSTREAM" };
|
|
}
|
|
}
|
|
|
|
/** Grok Build's `auth.x.ai` session and its billing view. */
|
|
async function grokQuota(dirs = toolDirs()) {
|
|
const sessions = readJson(path.join(dirs.grok, "auth.json"));
|
|
if (!sessions || typeof sessions !== "object") return null;
|
|
const xai = Object.entries(sessions)
|
|
.filter(([issuer, s]) => /^https:\/\/auth\.x\.ai(::|$)/.test(issuer) && s?.key)
|
|
.map(([, s]) => s);
|
|
const live = (s) => !s.expires_at || fresh(Date.parse(s.expires_at));
|
|
const s = xai.find(live) || xai[0];
|
|
if (!s) return null;
|
|
const who = {
|
|
id: "grok",
|
|
active: true,
|
|
account: s.email || null,
|
|
plan: null,
|
|
expiresAt: s.expires_at ? isoOf(s.expires_at) : null,
|
|
};
|
|
if (!live(s)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
|
|
const headers = {
|
|
"X-XAI-Token-Auth": "xai-grok-cli",
|
|
...(s.user_id && { "x-userid": String(s.user_id) }),
|
|
};
|
|
return bearerWindows(who, GROK_BILLING_URL, s.key, headers, (body) => {
|
|
const c = body?.config || body || {};
|
|
const windows = [];
|
|
// ponytail: a reply with no percent shows no week; Orca infers 0 from
|
|
// the period fields — add that if a real account ever answers so.
|
|
if (typeof c.creditUsagePercent === "number") {
|
|
windows.push({
|
|
label: "wk",
|
|
usedPercent: c.creditUsagePercent,
|
|
resetsAt: isoOf(c.currentPeriod?.end ?? c.billingPeriodEnd),
|
|
});
|
|
}
|
|
const limit = Number(c.monthlyLimit?.val);
|
|
const used = Number(c.used?.val);
|
|
if (limit > 0 && Number.isFinite(used)) {
|
|
windows.push({
|
|
label: "mo",
|
|
usedPercent: (used / limit) * 100,
|
|
resetsAt: isoOf(c.billingPeriodEnd),
|
|
});
|
|
}
|
|
return { plan: c.subscriptionTier || null, windows };
|
|
});
|
|
}
|
|
|
|
/** Kimi Code's OAuth token and its managed-plan usage. */
|
|
async function kimiQuota(dirs = toolDirs()) {
|
|
const cred = readJson(path.join(dirs.kimi, "credentials", "kimi-code.json"));
|
|
if (typeof cred?.access_token !== "string" || !cred.access_token) return null;
|
|
const exp = Number(cred.expires_at) * 1000;
|
|
const who = { id: "kimi", active: true, expiresAt: exp ? iso(exp) : null };
|
|
if (exp && !fresh(exp)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
|
|
return bearerWindows(who, KIMI_USAGE_URL, cred.access_token, {}, (body) => ({
|
|
windows: [
|
|
["5h", body?.usages?.limit_5h],
|
|
["wk", body?.usages?.limit_7d],
|
|
["mo", body?.usages?.limit_month_total],
|
|
]
|
|
.filter(([, u]) => u && Number.isFinite(Number(u.used_ratio)))
|
|
.map(([label, u]) => ({
|
|
label,
|
|
usedPercent: Number(u.used_ratio) * 100,
|
|
resetsAt: isoOf(u.reset_time),
|
|
})),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* A member's `opencode.db`, copied (with its WAL) from owner-checked reads
|
|
* into a private temp dir — SQLite opens by NAME, which the member could swap
|
|
* between a check and the open. The host's own is opened in place.
|
|
*/
|
|
function openCodeDbPath(dir, tmp) {
|
|
const db = path.join(dir, "opencode.db");
|
|
if (!s3Enabled() || !ownerForRead(db)) return db;
|
|
const copy = path.join(tmp(), "opencode.db");
|
|
fs.writeFileSync(copy, readFileSafe(db, null, { max: 256 << 20 })); // ponytail: whole file in memory, capped
|
|
try {
|
|
fs.writeFileSync(`${copy}-wal`, readFileSafe(`${db}-wal`, null, { max: 256 << 20 }));
|
|
} catch {
|
|
/* no WAL: everything is in the main file */
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
/** opencode's `opencode-go` key: `auth.json` (v1), else `opencode.db` (v2). */
|
|
function openCodeGoKey(dirs = toolDirs()) {
|
|
const dir = dirs.opencode;
|
|
const entry = readJson(path.join(dir, "auth.json"))?.["opencode-go"];
|
|
if (entry?.type === "api" && typeof entry.key === "string" && entry.key) return entry.key;
|
|
let conn;
|
|
let tmpDir = null;
|
|
const tmp = () => (tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-")));
|
|
try {
|
|
const file = openCodeDbPath(dir, tmp);
|
|
// The private copy is writable so SQLite can replay its WAL.
|
|
conn = new Database(file, { readonly: !tmpDir, fileMustExist: true });
|
|
const rows = conn
|
|
.prepare(
|
|
"SELECT value FROM credential WHERE integration_id = 'opencode-go' ORDER BY active DESC, time_created DESC LIMIT 8"
|
|
)
|
|
.all();
|
|
for (const r of rows) {
|
|
const v = JSON.parse(r.value);
|
|
if (typeof v?.key === "string" && v.key) return v.key;
|
|
}
|
|
} catch {
|
|
/* no OpenCode 2 database, or no credential table (OpenCode 1) */
|
|
} finally {
|
|
conn?.close();
|
|
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function openCodeGoQuota(dirs = toolDirs()) {
|
|
const key = openCodeGoKey(dirs);
|
|
if (!key) return null;
|
|
const who = { id: "opencode", active: true, plan: "Go" };
|
|
return bearerWindows(who, OPENCODE_GO_URL, key, {}, (body) => ({
|
|
windows: [
|
|
["5h", body?.usage?.rolling],
|
|
["wk", body?.usage?.weekly],
|
|
["mo", body?.usage?.monthly],
|
|
]
|
|
.filter(([, m]) => typeof m?.percent === "number")
|
|
.map(([label, m]) => ({ label, usedPercent: m.percent, resetsAt: isoOf(m.resetsAt) })),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Every tool's quota row. `home`: a member's Linux home (S3) — that account's
|
|
* logins only, and no agy; omitted, the host's.
|
|
*/
|
|
async function quotas({ force = false, home = null } = {}) {
|
|
const dirs = toolDirs(home);
|
|
const key = (k) => (home ? `${home}|${k}` : k);
|
|
const [claude, cursor, antigravity, grok, kimi, opencode] = await Promise.all([
|
|
claudeQuotas(force, home),
|
|
cached(key("cursor"), force, () => cursorQuota(dirs)),
|
|
home ? null : cached("antigravity", force, antigravityQuota),
|
|
cached(key("grok"), force, () => grokQuota(dirs)),
|
|
cached(key("kimi"), force, () => kimiQuota(dirs)),
|
|
cached(key("opencode"), force, () => openCodeGoQuota(dirs)),
|
|
]);
|
|
const tools = [
|
|
...claude,
|
|
codexQuota(dirs.codex),
|
|
antigravity,
|
|
cursor,
|
|
grok,
|
|
kimi,
|
|
opencode,
|
|
].filter(Boolean);
|
|
return { tools, checkedAt: new Date().toISOString() };
|
|
}
|
|
|
|
function __resetCache() {
|
|
cache.clear();
|
|
lastGood.clear();
|
|
}
|
|
|
|
module.exports = {
|
|
quotas,
|
|
codexWindow,
|
|
lastRateLimits,
|
|
codexAccount,
|
|
windowLabel,
|
|
agyVersionOk,
|
|
__resetCache,
|
|
};
|