Compare commits
4 commits
9279b15b7f
...
719d65129a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
719d65129a | ||
|
|
7f01e6afe5 | ||
|
|
e274ca7ed5 | ||
|
|
c888f7c633 |
9 changed files with 2022 additions and 28 deletions
|
|
@ -68,7 +68,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
|||
## Tests
|
||||
|
||||
- **CI** : `.forgejo/workflows/ci.yml` (issue #24), sur toute PR (pas de filtre `branches:`, pour que les maillons d'une pile soient batis) et sur `push` vers `main`. Un seul job en `container: node:22-alpine`, la base du Dockerfile : `npm ci`, `npm test`, `bash test-curl.sh`, puis `sh scripts/boot-from-copy.sh`. Verdict a lire sur le statut de commit (`GET /repos/maximus/vps-health-api/commits/<sha>/status`), l'API Actions repondant 404 sur cette instance.
|
||||
- `npm test` (vitest) — 500 cas
|
||||
- `npm test` (vitest) — 611 cas
|
||||
- `__tests__/claude-usage.test.js` — `POST`/`GET /claude-usage` et `sanitizeClaudeUsage()` (119 cas, issue #48 : contrat garde tel quel et idempotence ; corps entier refuse seulement hors objet ou `schemaVersion` != 1 ; valeurs invalides **jetees, pas forcees** (champ inconnu a chaque niveau, `__proto__`, nombre negatif, au-dela de 2^53, fractionnaire, `percent` hors 0-100, `status` hors enum, `detail` hors regex, dates ISO, jour inexistant, cle de modele) ; bornes de liste ; cloisonnement des trois tokens dans les deux sens ; 503 nommant chacun sa variable ; espion `timingSafeEqual` ; 413 a 65 537 octets et 204 a 65 536, `/hosts/<id>` toujours 413 a 4 097 ; ecriture atomique en mode 600 ; aller-retour POST puis GET ; peremption a la seconde pres sous horloge figee ; re-sanitisation et degradation en `no_data` a la lecture ; journalisation des rejets ; `claude-usage.js` dans le `COPY`)
|
||||
- `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
|
||||
- `__tests__/state.test.js` — `/defenseurs/state` + non-regression `/defenseurs` apres extraction de `serveJsonFile()` (9 cas : corps identique octet pour octet sur un JSON non canonique, no_data, isolation des deux chemins, 401 sans token / mauvais token / token d'ingestion, 404 sur POST)
|
||||
|
|
@ -76,6 +76,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
|||
- `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (67 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateur `Bearer` autre qu'un espace), quasi-rates du token sur les 6 routes de lecture, fail-closed, **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**, **un espion sur `timingSafeEqual`** qui exige deux empreintes de 32 octets a chaque requete, et **des epingles sur le source** : aucune egalite contre une chaine `Bearer`, un seul `tokenMatches()` et un seul `return null` dans `checkAuth()`, egalites limitees a une liste blanche, `tokenMatches()` qui hache les deux cotes sans test de longueur)
|
||||
- `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (173 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, `loadAvg` a exactement 3 elements, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture, **balayage des `.tmp` orphelins** au demarrage, prouve sur le vrai point d'entree, symlink ni suivi ni supprime, ancre `^` de `TEMP_FILE_RE`)
|
||||
- `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**)
|
||||
- `__tests__/agent-claude-usage.test.js` — agent claude-usage du ThinkPad (111 cas). Agregation des transcripts, #49 (43 cas) : jours Toronto et changement d'heure, regles de projet, deduplication des requetes, 30 jours, top 8 + `(autres)`, 12 modeles, seuil de scan pris sur le plus ancien jour ouvert (jour fige futur, trou dans la serie figee, tout fige), noms `__proto__` et `constructor` sans ecriture sur `Object.prototype`. Agent, #50 (68 cas) : reponse anonymisee de l'endpoint (`__tests__/fixtures/claude-usage/oauth-usage.json`) vers les items, repli `five_hour`/`seven_day`, tout `null` -> `unavailable` ; `fetch` (double) jamais appele sur jeton expire, `off`, credentials tronques, absents ou sans jeton, version absente ; 429 -> `http_error`/`http_429` avec les dernieres limites valides et leur `fetchedAt` ; corps hostiles -> `detail` dans l'enum ; bearer OAuth envoye a la seule URL Anthropic epinglee, aucune variable d'env ne la change (epingle sur le source), `redirect: "error"` ; **espions sur toutes les ecritures de `node:fs`** : le fichier de credentials n'est que lu ; aucun fragment de 5 caracteres des deux jetons dans le snapshot, l'etat ni la sortie (dont un vrai processus) ; deux passages autour d'un jour vide (fige, puis aucun fichier sous le seuil ouvert) ; `state.json` corrompu, elagage a 35 jours, ecriture atomique en 600 ; snapshot sur fixtures inchange par `sanitizeClaudeUsage` ; `http:` -> 1, > 64 Kio -> 2 sans envoi ; `run-claude-usage.sh` lance deux fois en parallele (faux `node`, HOME temporaire) : le second sort 0 sans travailler
|
||||
- `__tests__/ci.test.js` — epingles du workflow et des scripts de CI (48 cas : image du `container:` identique au `FROM` du Dockerfile, les quatre etapes presentes, aucun filtre `branches:` sous `pull_request`, `push` limite a `main`, ni `defaults:` ni `working-directory` sur une etape `uses:`, **aucun `continue-on-error`, sous toute ecriture litterale** (une etape rouge passerait au vert en silence ; detecteur sur le mot nu, sans ancrage ni deux-points exiges, lui-meme verifie sur la cle en bloc, en element de liste, en style flow, entre guillemets et via une ancre YAML ; seules les lignes de commentaire entieres en sont exemptees ; une cle ecrite avec une sequence d'echappement, `"continue\u002don-error"`, passe encore : l'epingle lit du texte, pas du YAML), ce que le boot check suppose du Dockerfile : `USER node`, `CMD` en forme exec, le **refus d'un port deja occupe** (voisin HTTP reel, script lance en `spawn` asynchrone), **aucune requete par un proxy de l'environnement** (boot complet derriere un faux proxy qui repond a tout, sa vivacite prouvee d'abord par un `curl` nu, avec un `no_proxy` qui laisse la boucle locale hors liste : exporter `NO_PROXY` seul ne suffit pas), **`test-curl.sh` lance en entier** (#41 et #46, 22 cas, chacun sur des ports libres qui lui sont propres, passes par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` : derriere le meme faux proxy avec `FORCE_COLOR=3`, avec `BASE_URL` et `NOINGEST_URL` pointes sur un port mort, et avec `TOKEN` et `INGEST_TOKEN` herites d'une meme valeur, rien n'atteint le proxy, les 45 cas passent, le nombre etant ecrit en dur dans le test, et les deux ports sont refuses des la premiere tentative a la sortie du script ; **deux passages au meme instant** sur quatre ports libres, 45 cas chacun ; les **refus de port** : valeur non numerique, port 0, port au-dela de 65535, nombre suivi de lettres (`80abc`), chacun sur chacune des deux variables, puis deux ports identiques, et les **deux bornes acceptees**, 1 et 65535, donnees aux deux variables a la fois pour ne rien faire ecouter sur un port fixe (atteindre le refus « must differ » montre que la valeur a passe le controle de plage), message sur stderr et code 1 avant tout lancement ; un **voisin HTTP reel** sur le port principal, puis sur le second : refus avant tout lancement, le voisin n'ayant vu que la sonde `/` ; un **occupant non HTTP** sur le port principal, qui ferme aussitot (code curl 52 ou 56) puis qui accepte sans repondre (28) : meme refus, la regle etant « tout code autre que 7 » ; un **serveur vivant qui n'ecoute jamais** : message d'echeance et code 1 apres les 5 s d'attente ; puis un faux `node` en tete de `PATH` qui sort en 1 pour le port principal, puis le second : message sur stderr, code 1 en moins de 3 s, aucun serveur laisse sur son port ; et deux **epingles sur le source** du script : defauts 3099 et 3098, seules occurrences de ces nombres hors commentaires, et URLs derivees des ports sans lecture de l'environnement. Plus aucun cas n'ecoute sur 3099 ni 3098 : les defauts ne sont exerces que par l'etape CI « Socket-level pass » et par un `bash test-curl.sh` a la main. Les ports viennent de `freePorts()`, qui ouvre ses sondes ensemble puis les ferme avant le lancement : un port repris dans l'intervalle fait echouer le cas avec le message de `require_free`, sans cas lance contre un mauvais serveur), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`. Cette derniere epingle est une heuristique sur les appels litteraux, espace avant la parenthese comprise (`require (`, `import (`). Son detecteur, `loadsLate()`, est lui-meme verifie sur six lignes : `require` indente et `import` a toute colonne, avec ou sans espace, signales ; `require` de premier niveau et ligne de commentaire laisses passer. Trous connus, entre autres, laisses ouverts (#35) : une fonction ecrite entiere sur une ligne en colonne 0 (`const lazy = () => require("x");`), un `require(` renvoye a la ligne en colonne 0 dans une fonction (de meme forme que le `} = require("node:fs");` legitime d'`index.js`), et toute indirection, comme un alias de `require`)
|
||||
- `__tests__/writes.test.js` — surfaces d'ecriture : `HOSTS_DIR`, et le dossier de `CLAUDE_USAGE_PATH` pour le seul `POST /claude-usage` (#48 : `violations()` prend le dossier autorise du cas, `HOSTS_DIR` par defaut ; deux cas dedies, `POST /claude-usage` n'ecrit que dans son dossier, et ses rejets 401/400/413 et la lecture d'un fichier corrompu n'ecrivent rien du tout ; `GET /claude-usage` entre dans les routes de lecture) (14 cas : espions sur toutes les fonctions d'ecriture de `node:fs`, formes callback, Sync et `fs.promises`, et sur `child_process`, poses avant un `require` frais apres purge du cache de **tous** les modules runtime (un `metrics.js` reste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des `.tmp` ; les six routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; `POST /hosts/<id>` n'ecrit que sous `HOSTS_DIR` ; **chemins degrades** : rejets 401/403/404, entrees 400/413, `agents-map.json` corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (`no_data`, `neverSeen`) ; **commandes shell** figees sur `head -1 /proc/stat` et `df -k /`, `/health` servant de temoin ; chaque cas prouve que ses espions sont vivants ; **tout appel est compte, sans attribution par pile** (#40 : une fonction `node:fs` passee telle quelle a `process.nextTick`, a un ecouteur ou a un `.then` s'execute sans frame runtime sur sa pile, et un filtre par pile la laissait passer) ; les retouches de fixtures du cas degrade passent par les fonctions reelles capturees avant la pose des espions, jamais par un `rmSync` recursif, qui appelle en interne le `fs.rmdirSync` public, donc l'espion ; **epingle sur le bloc `require.main`**, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms. Contrepartie assumee : une ecriture du lanceur de tests lui-meme pendant un cas le ferait rougir)
|
||||
- Runtime reste 0-dep ; vitest en devDep uniquement
|
||||
|
|
@ -87,6 +88,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
|||
- Pas d'Express — HTTP natif Node.js uniquement
|
||||
- Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js claude-usage.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que `npm test` ne voit pas (seul `scripts/boot-from-copy.sh`, en CI, le rattrape). Un `require` place dans un handler de route echapperait au boot check (qui n'appelle que `/health`) : garder tous les `require` au niveau module, une epingle de `ci.test.js` l'exige. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais.
|
||||
- `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env.
|
||||
- `agent/claude-usage/` (#50, `logger -t claude-usage`, cron 15 min) tient deux secrets dans le meme processus : le jeton OAuth de Claude Code et `CLAUDE_USAGE_INGEST_TOKEN`. Il ne rafraichit **jamais** le jeton OAuth et n'ecrit **jamais** `~/.claude/.credentials.json` (refresh token a usage unique : Claude Code serait deconnecte) ; jeton expire -> `token_expired`, aucun appel. L'URL Anthropic est une constante de `limits.js` (aucune variable d'env, `redirect: "error"`) pour que le bearer OAuth ne suive jamais l'URL de push configurable ; `detail` ne sort que de l'enum, jamais d'un message d'erreur ou d'un corps de reponse. Memes regles de sortie que l'agent poste (codes seulement, pas de `set -x` dans `run-claude-usage.sh`).
|
||||
- `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`.
|
||||
- L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/<id>` ecrit dans `HOSTS_DIR`, et `POST /claude-usage` (#48) dans le dossier de `CLAUDE_USAGE_PATH`. Consequence : ces deux montages-la doivent etre en lecture-ecriture pour l'uid 1000, alors que les deux montages Defenseurs sont en `:ro` (#36). Une ecriture ajoutee un jour sous `REPORTS_DIR` ou `/data/defenseurs` passerait tous les autres tests (fixtures inscriptibles, CI en root) et tomberait en prod en 500 `EROFS` : `__tests__/writes.test.js` exige que toute ecriture par `node:fs` vise `HOSTS_DIR` (ou, pour le seul cas `POST /claude-usage`, le dossier de `CLAUDE_USAGE_PATH`), et que les seules commandes shell soient les deux lectures de `metrics.js`, sur chaque chemin qu'il exerce (demarrage, lectures et ingestion nominales, rejets, entrees invalides, fichier corrompu, fichiers absents). Un nouveau chemin qui ecrit doit y entrer comme cas.
|
||||
- L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync` — `rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct. Un processus tue entre les deux laisse un `.<id>.<pid>.<ms>.tmp` : `sweepStaleTempFiles()` les supprime au demarrage, avant `listen`, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (`lstat`) ; changer le nom du fichier temporaire impose de changer `TEMP_FILE_RE`. `writeClaudeUsage()` suit le meme patron (temporaire `.claude-usage.json.<pid>.<ms>.tmp`), mais **aucun balayage** ne couvre son dossier : un orphelin y resterait, inoffensif (`GET /claude-usage` ne lit que le fichier final) ; un seul fichier ecrit toutes les quelques minutes ne justifiait pas d'elargir `sweepStaleTempFiles()` et le bloc `require.main` epingle.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
53
__tests__/fixtures/claude-usage/oauth-usage.json
Normal file
53
__tests__/fixtures/claude-usage/oauth-usage.json
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"five_hour": { "utilization": 1.0, "resets_at": "2026-09-27T21:30:00.512345+00:00" },
|
||||
"seven_day": { "utilization": 50.0, "resets_at": "2026-10-01T07:00:00.512345+00:00" },
|
||||
"seven_day_oauth_apps": null,
|
||||
"seven_day_opus": null,
|
||||
"seven_day_sonnet": null,
|
||||
"seven_day_fable": null,
|
||||
"iguana_necktie": null,
|
||||
"extra_usage": null,
|
||||
"opaque_a": null,
|
||||
"opaque_b": null,
|
||||
"opaque_c": null,
|
||||
"opaque_d": null,
|
||||
"opaque_e": null,
|
||||
"opaque_f": null,
|
||||
"opaque_g": null,
|
||||
"opaque_h": null,
|
||||
"opaque_i": null,
|
||||
"opaque_j": null,
|
||||
"opaque_k": null,
|
||||
"opaque_l": null,
|
||||
"opaque_m": null,
|
||||
"opaque_n": null,
|
||||
"limits": [
|
||||
{
|
||||
"kind": "session",
|
||||
"group": "session",
|
||||
"percent": 1,
|
||||
"severity": "normal",
|
||||
"resets_at": "2026-09-27T21:30:00Z",
|
||||
"scope": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"kind": "weekly_all",
|
||||
"group": "weekly",
|
||||
"percent": 50,
|
||||
"severity": "normal",
|
||||
"resets_at": "2026-10-01T07:00:00Z",
|
||||
"scope": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"kind": "weekly_scoped",
|
||||
"group": "weekly",
|
||||
"percent": 63,
|
||||
"severity": "normal",
|
||||
"resets_at": "2026-10-01T07:00:00Z",
|
||||
"scope": { "model": { "display_name": "Fable" } },
|
||||
"is_active": true
|
||||
}
|
||||
]
|
||||
}
|
||||
120
agent/README.md
120
agent/README.md
|
|
@ -178,3 +178,123 @@ Sharing `metrics.js` with the server keeps the two in step, but it is not the
|
|||
guarantee — the frozen copy can lag behind a deployed server. The real contract
|
||||
is the server's own validation, and `__tests__/agent.test.js` checks the agent's
|
||||
payload against it (including a real snapshot through the real handler).
|
||||
|
||||
## claude-usage agent (`agent/claude-usage/`)
|
||||
|
||||
A second, independent agent on the ThinkPad. Every 15 minutes it reads the
|
||||
Claude plan limits, aggregates the local Claude Code transcripts over the last
|
||||
30 Toronto days, and pushes one `schemaVersion: 1` snapshot to
|
||||
`POST /claude-usage`; the admin dashboard reads it through `GET /claude-usage`.
|
||||
|
||||
It lives in this repo, next to the server, because the snapshot contract and
|
||||
its sanitizer (`claude-usage.js`) are tested together:
|
||||
`__tests__/agent-claude-usage.test.js` builds a real snapshot from fixtures and
|
||||
checks that `sanitizeClaudeUsage()` keeps it unchanged. Like the host agent, it
|
||||
never enters the server image.
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `aggregate.js` | Read `~/.claude/projects/**/*.jsonl`, build `days` and `projects` |
|
||||
| `limits.js` | One GET to Anthropic's OAuth usage endpoint, mapped to `limits.items` |
|
||||
| `state.js` | Local cache: frozen days and last good limits |
|
||||
| `push-claude-usage.js` | Entry point: aggregate, limits, write the cache, push once, exit |
|
||||
| `run-claude-usage.sh` | Cron wrapper: lock, load the env file, check the install, exec node |
|
||||
|
||||
### What it deliberately does not do
|
||||
|
||||
**It never refreshes the OAuth token and never writes
|
||||
`~/.claude/.credentials.json`.** Refresh tokens are single use: refreshing from
|
||||
here would log Claude Code out. The file is only read; an expired token gives
|
||||
`limits.status: "token_expired"` and no call at all, until Claude Code itself
|
||||
refreshes it.
|
||||
|
||||
**The Anthropic URL is pinned.** It is a constant in `limits.js`, no
|
||||
environment variable changes it, and redirects are refused: the same process
|
||||
holds the OAuth bearer and a configurable push URL, and the bearer must only
|
||||
ever reach `https://api.anthropic.com`. `CLAUDE_USAGE_API_URL` must be
|
||||
`https://` (the agent exits 1 otherwise).
|
||||
|
||||
**One call, one push, no retry.** The usage endpoint rate-limits hard and sends
|
||||
no `Retry-After`. When it fails, the snapshot carries the failure status
|
||||
(`http_error`, `unavailable`, ...) with the last good items and their
|
||||
`fetchedAt`, so the dashboard can show how old they are.
|
||||
|
||||
**No secret in the logs.** Same rule as the host agent: the output goes to
|
||||
`logger -t claude-usage`, so failures print fixed templates plus an HTTP status
|
||||
or an error code — never an error object, a response body, or a header — and
|
||||
`run-claude-usage.sh` must never gain a `set -x`.
|
||||
|
||||
### Install
|
||||
|
||||
Freeze a copy — never point the cron at the git working tree:
|
||||
|
||||
```
|
||||
mkdir -p ~/.local/share/maximus-claude-usage
|
||||
cp agent/claude-usage/aggregate.js agent/claude-usage/limits.js agent/claude-usage/state.js agent/claude-usage/push-claude-usage.js agent/claude-usage/run-claude-usage.sh ~/.local/share/maximus-claude-usage/
|
||||
chmod +x ~/.local/share/maximus-claude-usage/run-claude-usage.sh
|
||||
```
|
||||
|
||||
Re-run that `cp` after any change to the agent: the copy step *is* the
|
||||
deployment. The code lives in `~/.local/share/`, the cache in
|
||||
`~/.local/state/maximus-claude-usage/` (`state.json` and `run.lock`, created on
|
||||
the first run): wiping the copy never loses the cache, and vice versa.
|
||||
|
||||
Env file `~/.config/maximus-claude-usage.env`, mode `600` (the wrapper warns on
|
||||
any other mode). Write it with `printf`, not a heredoc: a trailing newline in
|
||||
the token is refused with exit 1.
|
||||
|
||||
```
|
||||
CLAUDE_USAGE_API_URL=https://health.lacompagniemaximus.com
|
||||
CLAUDE_USAGE_INGEST_TOKEN=<the claude-usage ingest token>
|
||||
# CLAUDE_USAGE_LIMITS=off # skip the Anthropic call entirely
|
||||
# NODE_BIN=/usr/bin/node # only if cron cannot find node
|
||||
```
|
||||
|
||||
```
|
||||
chmod 600 ~/.config/maximus-claude-usage.env
|
||||
```
|
||||
|
||||
`CLAUDE_USAGE_INGEST_TOKEN` is its own token: neither `HOSTS_INGEST_TOKEN` nor
|
||||
`HEALTH_TOKEN` works on `POST /claude-usage`, and it opens nothing else.
|
||||
|
||||
Dry run — prints the snapshot, pushes nothing, leaves the cache untouched, needs
|
||||
no env file (it still calls the Anthropic endpoint once unless
|
||||
`CLAUDE_USAGE_LIMITS=off`):
|
||||
|
||||
```
|
||||
CLAUDE_USAGE_LIMITS=off node ~/.local/share/maximus-claude-usage/push-claude-usage.js --dry-run
|
||||
```
|
||||
|
||||
Then one real run by hand, before installing the cron (the first one scans every
|
||||
transcript of the window and can take a while; the next ones only read files
|
||||
written since yesterday):
|
||||
|
||||
```
|
||||
~/.local/share/maximus-claude-usage/run-claude-usage.sh
|
||||
# claude-usage: ok status=204 limits=ok bytes=...
|
||||
```
|
||||
|
||||
Crontab line:
|
||||
|
||||
```
|
||||
*/15 * * * * $HOME/.local/share/maximus-claude-usage/run-claude-usage.sh 2>&1 | logger -t claude-usage
|
||||
```
|
||||
|
||||
```
|
||||
journalctl -t claude-usage --since -1h
|
||||
```
|
||||
|
||||
A beat that finds the previous one still running (`flock` on
|
||||
`~/.local/state/maximus-claude-usage/run.lock`) logs
|
||||
`a run is already in progress, skipping` and exits 0.
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Snapshot accepted (2xx), or a skipped beat (lock held) |
|
||||
| 1 | Install problem: env file missing, a variable unset, URL not `https://`, token with an invalid character, node or flock not found |
|
||||
| 2 | The push did not land (network or non-2xx), or the snapshot exceeded 64 KiB (`payload_too_large`, nothing sent) |
|
||||
|
||||
The limits failing is **not** an exit code: the snapshot is still pushed, with
|
||||
`limits.status` saying why.
|
||||
|
|
|
|||
|
|
@ -18,9 +18,13 @@
|
|||
// are deduplicated on (message.id, requestId) across ALL files, keeping the
|
||||
// line with the largest output_tokens.
|
||||
// * THE SCAN THRESHOLD. Frozen days are never recomputed. A file whose mtime
|
||||
// (Toronto day) is before the day after the most recent frozen day cannot
|
||||
// hold a line on an open day, so it is not even opened: this is what keeps
|
||||
// a run from re-reading ~210 MB of transcripts every time.
|
||||
// (Toronto day) is before the oldest open day of the window cannot hold a
|
||||
// line on an open day, so it is not even opened: this is what keeps a run
|
||||
// from re-reading ~210 MB of transcripts every time. The threshold comes
|
||||
// from the open days, never from the frozen keys: a stray frozen day (in
|
||||
// the future, or after a gap) must not hide an open day's files.
|
||||
// * NO PROTOTYPE. Names read from transcripts or the state file key plain
|
||||
// maps built by dict(), so `__proto__` or `constructor` stay ordinary keys.
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
|
@ -136,6 +140,13 @@ function count(value) {
|
|||
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
// Every map keyed by a name read from a transcript or the state file (model,
|
||||
// project, day) has no prototype: a project named `__proto__` or
|
||||
// `constructor` is then a plain key, never a write into Object.prototype.
|
||||
function dict() {
|
||||
return Object.create(null);
|
||||
}
|
||||
|
||||
function emptyModel() {
|
||||
return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, requests: 0 };
|
||||
}
|
||||
|
|
@ -179,7 +190,7 @@ function capModels(models) {
|
|||
(a < b ? -1 : a > b ? 1 : 0)
|
||||
);
|
||||
});
|
||||
const out = {};
|
||||
const out = dict();
|
||||
for (const name of names.slice(0, MAX_MODELS_PER_DAY - 1)) out[name] = models[name];
|
||||
const other = emptyModel();
|
||||
if (hasOther) addModel(other, models[OTHER_MODEL]);
|
||||
|
|
@ -191,8 +202,8 @@ function capModels(models) {
|
|||
// Frozen days come from the agent's state file: re-read through the same
|
||||
// counters, never trusted verbatim.
|
||||
function normalizeDay(day) {
|
||||
const models = {};
|
||||
const projects = {};
|
||||
const models = dict();
|
||||
const projects = dict();
|
||||
const srcModels = day && typeof day.models === "object" && day.models ? day.models : {};
|
||||
const srcProjects =
|
||||
day && typeof day.projects === "object" && day.projects ? day.projects : {};
|
||||
|
|
@ -294,7 +305,7 @@ async function aggregate({
|
|||
} = {}) {
|
||||
if (!isDay(today)) throw new Error("aggregate: today must be a YYYY-MM-DD date");
|
||||
|
||||
const frozen = {};
|
||||
const frozen = dict();
|
||||
if (frozenDays && typeof frozenDays === "object") {
|
||||
for (const [day, value] of Object.entries(frozenDays)) {
|
||||
if (isDay(day)) frozen[day] = value;
|
||||
|
|
@ -305,15 +316,13 @@ async function aggregate({
|
|||
const windowStart = window[0];
|
||||
const openDayList = window.filter((d) => !Object.prototype.hasOwnProperty.call(frozen, d));
|
||||
|
||||
// Scan threshold: the day after the most recent frozen day. Floored at the
|
||||
// window start — a file last written before the window cannot hold a line
|
||||
// inside it.
|
||||
const frozenList = Object.keys(frozen).sort();
|
||||
let threshold = windowStart;
|
||||
if (frozenList.length) {
|
||||
const afterFrozen = shiftDay(frozenList[frozenList.length - 1], 1);
|
||||
if (afterFrozen > threshold) threshold = afterFrozen;
|
||||
}
|
||||
// Scan threshold: the oldest open day of the window. With the agent's
|
||||
// contiguous frozen prefix this is the day after the most recent frozen day;
|
||||
// computed from the open days rather than from the frozen keys, so a frozen
|
||||
// day in the future or a gap in the frozen run can never push the threshold
|
||||
// past an open day and silently empty it. A file last written before the
|
||||
// window cannot hold a line inside it. Every day frozen: nothing to read.
|
||||
const threshold = openDayList.length ? openDayList[0] : shiftDay(today, 1);
|
||||
|
||||
// (message.id, requestId) -> best line so far.
|
||||
const requests = new Map();
|
||||
|
|
@ -383,8 +392,8 @@ async function aggregate({
|
|||
// Open days: every non-frozen day of the window, empty ones included, so
|
||||
// the caller can freeze them all (an unfrozen empty day would hold the
|
||||
// threshold back and every run would re-read the whole window).
|
||||
const openDays = {};
|
||||
for (const day of openDayList) openDays[day] = { models: {}, projects: {} };
|
||||
const openDays = dict();
|
||||
for (const day of openDayList) openDays[day] = { models: dict(), projects: dict() };
|
||||
for (const r of requests.values()) {
|
||||
const bucket = openDays[r.day];
|
||||
if (!bucket.models[r.model]) bucket.models[r.model] = emptyModel();
|
||||
|
|
@ -399,7 +408,7 @@ async function aggregate({
|
|||
|
||||
// Merged 30-day series and project totals over the window.
|
||||
const days = [];
|
||||
const projectTotals = {};
|
||||
const projectTotals = dict();
|
||||
for (const date of window) {
|
||||
const source = Object.prototype.hasOwnProperty.call(frozen, date)
|
||||
? normalizeDay(frozen[date])
|
||||
|
|
|
|||
276
agent/claude-usage/limits.js
Normal file
276
agent/claude-usage/limits.js
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
// Claude plan limits for the claude-usage agent (issue #50).
|
||||
//
|
||||
// One GET to Anthropic's (undocumented) OAuth usage endpoint, with the access
|
||||
// token Claude Code already stored in ~/.claude/.credentials.json. Returns the
|
||||
// `limits` block of the POST /claude-usage contract (schemaVersion 1), minus
|
||||
// the "last good values" fallback, which state.js owns.
|
||||
//
|
||||
// Properties that are load-bearing and easy to break:
|
||||
//
|
||||
// * THE CREDENTIALS FILE IS READ, NEVER WRITTEN. Refresh tokens are single
|
||||
// use: refreshing here would log Claude Code out. An expired token means
|
||||
// `token_expired` and no call at all. The file is only ever passed to
|
||||
// fs.readFileSync (no flag), and __tests__/agent-claude-usage.test.js
|
||||
// spies on every writing function of node:fs to prove it.
|
||||
// * THE BEARER ONLY GOES TO USAGE_URL. The same process also holds a
|
||||
// configurable push URL; the OAuth token must never follow it. The URL is
|
||||
// a constant (no environment variable changes it) and redirects are
|
||||
// refused (`redirect: "error"`), so the token cannot be bounced elsewhere.
|
||||
// * `detail` IS AN ENUM. It comes from DETAILS below and nothing else, never
|
||||
// from an error message or a response body: both can carry upstream text,
|
||||
// and this value ends up in the admin dashboard.
|
||||
// * ONE CALL, NO RETRY. The endpoint rate-limits hard and sends no
|
||||
// Retry-After; a failed call waits for the next cron beat (15 min).
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
||||
const ANTHROPIC_BETA = "oauth-2025-04-20";
|
||||
const LIMITS_TIMEOUT_MS = 10000;
|
||||
// Far beyond the ~2 KB the endpoint answers; past it the body is not parsed.
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
const MAX_ITEMS = 12;
|
||||
const MAX_STRING = 128;
|
||||
|
||||
const DETAILS = {
|
||||
NO_CREDENTIALS: "no_credentials",
|
||||
PARSE_ERROR: "parse_error",
|
||||
NO_VERSION: "no_version",
|
||||
TIMEOUT: "timeout",
|
||||
NETWORK: "network",
|
||||
BAD_RESPONSE: "bad_response",
|
||||
};
|
||||
|
||||
// Same shape as the server sanitizer's timestamp check: a value outside it
|
||||
// would be dropped there, so it is normalized (or nulled) here instead.
|
||||
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
||||
|
||||
function defaultCredentialsPath(home = os.homedir()) {
|
||||
return path.join(home, ".claude", ".credentials.json");
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function failure(status, detail) {
|
||||
const out = { status, fetchedAt: null, items: [] };
|
||||
if (detail) out.detail = detail;
|
||||
return out;
|
||||
}
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === "string" ? value.slice(0, MAX_STRING) : null;
|
||||
}
|
||||
|
||||
// The contract wants an integer 0-100. Upstream sends a number that can be
|
||||
// fractional (utilization) and could overshoot 100 once a limit is exceeded.
|
||||
function cleanPercent(value) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||
return Math.min(100, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
// ISO string kept as sent when it already has the contract's shape; any other
|
||||
// parseable string, or an epoch number (seconds or milliseconds), is
|
||||
// re-rendered with toISOString; anything else is null.
|
||||
function cleanResetsAt(value) {
|
||||
if (typeof value === "string") {
|
||||
if (value.length <= MAX_STRING && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value))) {
|
||||
return value;
|
||||
}
|
||||
const ms = Date.parse(value);
|
||||
return Number.isNaN(ms) ? null : new Date(ms).toISOString();
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
const ms = value < 1e11 ? value * 1000 : value;
|
||||
const date = new Date(ms);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function item({ kind, group, percent, severity, resetsAt, model }) {
|
||||
const out = { kind, group };
|
||||
if (percent !== undefined) out.percent = percent;
|
||||
if (typeof severity === "string") out.severity = severity;
|
||||
out.resetsAt = resetsAt;
|
||||
out.model = model;
|
||||
return out;
|
||||
}
|
||||
|
||||
// `limits[]` entries: { kind, group, percent, severity, resets_at,
|
||||
// scope: { model: { display_name } } | null, is_active }. Built field by field,
|
||||
// so no upstream key reaches the snapshot unless it is listed here.
|
||||
function itemsFromLimits(list) {
|
||||
const items = [];
|
||||
for (const entry of list) {
|
||||
if (items.length >= MAX_ITEMS) break;
|
||||
if (!isPlainObject(entry) || typeof entry.kind !== "string" || !entry.kind) continue;
|
||||
const scope = isPlainObject(entry.scope) ? entry.scope : null;
|
||||
const model = scope && isPlainObject(scope.model) ? cleanString(scope.model.display_name) : null;
|
||||
items.push(
|
||||
item({
|
||||
kind: entry.kind.slice(0, MAX_STRING),
|
||||
group: typeof entry.group === "string" ? entry.group.slice(0, MAX_STRING) : entry.kind.slice(0, MAX_STRING),
|
||||
percent: cleanPercent(entry.percent),
|
||||
severity: cleanString(entry.severity),
|
||||
resetsAt: cleanResetsAt(entry.resets_at),
|
||||
model,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Older shape: five_hour / seven_day { utilization, resets_at }.
|
||||
function itemsFromWindows(body) {
|
||||
const items = [];
|
||||
const windows = [
|
||||
["five_hour", "session", "session"],
|
||||
["seven_day", "weekly_all", "weekly"],
|
||||
];
|
||||
for (const [key, kind, group] of windows) {
|
||||
const w = body[key];
|
||||
if (!isPlainObject(w)) continue;
|
||||
const percent = cleanPercent(w.utilization);
|
||||
if (percent === undefined) continue;
|
||||
items.push(item({ kind, group, percent, severity: null, resetsAt: cleanResetsAt(w.resets_at), model: null }));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseUsage(body) {
|
||||
if (!isPlainObject(body)) return [];
|
||||
if (Array.isArray(body.limits)) {
|
||||
const items = itemsFromLimits(body.limits);
|
||||
if (items.length) return items;
|
||||
}
|
||||
return itemsFromWindows(body);
|
||||
}
|
||||
|
||||
// Read the OAuth credentials. Never throws, never logs the content.
|
||||
function readCredentials(credentialsPath, readFile) {
|
||||
let raw;
|
||||
try {
|
||||
raw = readFile(credentialsPath);
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") return { detail: DETAILS.NO_CREDENTIALS };
|
||||
return { detail: DETAILS.PARSE_ERROR };
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
// Claude Code rewrites this file in place: a half-written JSON is a normal
|
||||
// occurrence, and the next beat will read the finished one.
|
||||
parsed = JSON.parse(String(raw));
|
||||
} catch {
|
||||
return { detail: DETAILS.PARSE_ERROR };
|
||||
}
|
||||
const oauth = isPlainObject(parsed) && isPlainObject(parsed.claudeAiOauth) ? parsed.claudeAiOauth : null;
|
||||
if (!oauth || typeof oauth.accessToken !== "string" || !oauth.accessToken) {
|
||||
return { detail: DETAILS.NO_CREDENTIALS };
|
||||
}
|
||||
return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt };
|
||||
}
|
||||
|
||||
async function readBody(res) {
|
||||
const text = await res.text();
|
||||
if (text.length > MAX_RESPONSE_BYTES) throw new Error("too large");
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function discardBody(res) {
|
||||
try {
|
||||
if (res && res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {});
|
||||
} catch {
|
||||
// nothing to release
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the plan limits once.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} [opts.env] process environment (only CLAUDE_USAGE_LIMITS is read)
|
||||
* @param {string} [opts.credentialsPath] ~/.claude/.credentials.json by default
|
||||
* @param {Function} [opts.readFile] (path) => string, read-only — injectable for tests
|
||||
* @param {Function} [opts.fetch] fetch implementation — injectable for tests
|
||||
* @param {string|null} opts.claudeCodeVersion version for the User-Agent (from the transcripts)
|
||||
* @param {number} [opts.now] epoch ms
|
||||
* @param {number} [opts.timeoutMs]
|
||||
* @returns {Promise<{status: string, detail?: string, fetchedAt: string|null, items: object[]}>}
|
||||
*/
|
||||
async function fetchLimits({
|
||||
env = process.env,
|
||||
credentialsPath = defaultCredentialsPath(),
|
||||
readFile = (file) => fs.readFileSync(file, "utf-8"),
|
||||
fetch: fetchImpl = globalThis.fetch,
|
||||
claudeCodeVersion = null,
|
||||
now = Date.now(),
|
||||
timeoutMs = LIMITS_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
if (String(env.CLAUDE_USAGE_LIMITS || "").trim().toLowerCase() === "off") {
|
||||
return failure("disabled");
|
||||
}
|
||||
|
||||
const creds = readCredentials(credentialsPath, readFile);
|
||||
if (creds.detail) return failure("unavailable", creds.detail);
|
||||
|
||||
// Never refreshed: the refresh token is single use and belongs to Claude
|
||||
// Code. A missing expiresAt is left to the server to judge (a 401 then).
|
||||
if (typeof creds.expiresAt === "number" && Number.isFinite(creds.expiresAt) && creds.expiresAt <= now) {
|
||||
return failure("token_expired");
|
||||
}
|
||||
|
||||
if (typeof claudeCodeVersion !== "string" || !claudeCodeVersion) {
|
||||
return failure("unavailable", DETAILS.NO_VERSION);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetchImpl(USAGE_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${creds.accessToken}`,
|
||||
"anthropic-beta": ANTHROPIC_BETA,
|
||||
"User-Agent": `claude-code/${claudeCodeVersion}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
const name = err && err.name;
|
||||
return failure("unavailable", name === "TimeoutError" || name === "AbortError" ? DETAILS.TIMEOUT : DETAILS.NETWORK);
|
||||
}
|
||||
|
||||
const status = res && Number.isInteger(res.status) ? res.status : 0;
|
||||
if (status < 200 || status > 299) {
|
||||
discardBody(res);
|
||||
return failure("http_error", status >= 100 && status <= 599 ? `http_${status}` : DETAILS.BAD_RESPONSE);
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await readBody(res);
|
||||
} catch (err) {
|
||||
const name = err && err.name;
|
||||
if (name === "TimeoutError" || name === "AbortError") return failure("unavailable", DETAILS.TIMEOUT);
|
||||
return failure("unavailable", DETAILS.BAD_RESPONSE);
|
||||
}
|
||||
|
||||
const items = parseUsage(body);
|
||||
if (!items.length) return failure("unavailable", DETAILS.BAD_RESPONSE);
|
||||
return { status: "ok", fetchedAt: new Date(now).toISOString(), items };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchLimits,
|
||||
parseUsage,
|
||||
defaultCredentialsPath,
|
||||
USAGE_URL,
|
||||
ANTHROPIC_BETA,
|
||||
LIMITS_TIMEOUT_MS,
|
||||
DETAILS,
|
||||
};
|
||||
238
agent/claude-usage/push-claude-usage.js
Executable file
238
agent/claude-usage/push-claude-usage.js
Executable file
|
|
@ -0,0 +1,238 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// claude-usage agent entry point (issue #50). Runs from the ThinkPad's
|
||||
// crontab every 15 minutes through run-claude-usage.sh:
|
||||
//
|
||||
// 1. aggregate the local transcripts (aggregate.js), frozen days excluded;
|
||||
// 2. read the plan limits once (limits.js);
|
||||
// 3. update and write the local cache (state.js) BEFORE pushing, so a failed
|
||||
// push never costs the next beat a full rescan;
|
||||
// 4. assemble the schemaVersion 1 snapshot, measure it, push it once.
|
||||
//
|
||||
// Zero runtime dependencies (Node 22 global fetch). Same two rules as
|
||||
// agent/push-metrics.js, for the same reason — the output goes to
|
||||
// `logger -t claude-usage`, so into syslog and journald for good:
|
||||
//
|
||||
// * NO QUEUE, NO REPLAY. One beat = one attempt; a failure exits non-zero.
|
||||
// * THE OUTPUT NEVER CARRIES A SECRET. Neither the ingest token nor the
|
||||
// OAuth token is printed. Failures are rebuilt from fixed templates plus an
|
||||
// HTTP status or a sanitized error code — never an error object, a message
|
||||
// from the HTTP stack (undici quotes an invalid header value in it), the
|
||||
// request options or a response body.
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { aggregate, torontoDay, TIME_ZONE } = require("./aggregate.js");
|
||||
const { fetchLimits, defaultCredentialsPath } = require("./limits.js");
|
||||
const {
|
||||
loadState,
|
||||
saveState,
|
||||
nextState,
|
||||
limitsForSnapshot,
|
||||
defaultStateDir,
|
||||
stateFilePath,
|
||||
} = require("./state.js");
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
// Same ceiling as the server (CLAUDE_USAGE_MAX_BODY_BYTES): a larger body
|
||||
// would be refused with a 413, so it is not even sent.
|
||||
const MAX_BODY_BYTES = 65536;
|
||||
const PUSH_TIMEOUT_MS = 10000;
|
||||
|
||||
const EXIT_OK = 0;
|
||||
const EXIT_CONFIG = 1;
|
||||
const EXIT_PUSH = 2;
|
||||
|
||||
const PREFIX = "claude-usage:";
|
||||
|
||||
// A token is printable ASCII without spaces. Checked up front: fetch rejects
|
||||
// an invalid header value with a message that QUOTES it.
|
||||
const TOKEN_RE = /^[\x21-\x7e]+$/;
|
||||
// What may be printed of an error: a system or undici code (ECONNREFUSED,
|
||||
// UND_ERR_CONNECT_TIMEOUT: upper case only) or an error class name
|
||||
// (TimeoutError, TypeError). Anything else, whatever field it came from, is
|
||||
// replaced by UNKNOWN rather than trusted.
|
||||
const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]{1,39}$/;
|
||||
const ERROR_NAME_RE = /^[A-Z][A-Za-z]{0,30}Error$/;
|
||||
|
||||
function safeCode(err) {
|
||||
const codes = [err && err.cause && err.cause.code, err && err.code];
|
||||
for (const c of codes) {
|
||||
if (typeof c === "string" && ERROR_CODE_RE.test(c)) return c;
|
||||
}
|
||||
const name = err && err.name;
|
||||
return typeof name === "string" && ERROR_NAME_RE.test(name) ? name : "UNKNOWN";
|
||||
}
|
||||
|
||||
function readConfig(env) {
|
||||
const apiUrl = String(env.CLAUDE_USAGE_API_URL || "").trim();
|
||||
const token = env.CLAUDE_USAGE_INGEST_TOKEN || "";
|
||||
if (!apiUrl) return { error: "CLAUDE_USAGE_API_URL is not set" };
|
||||
if (!token) return { error: "CLAUDE_USAGE_INGEST_TOKEN is not set" };
|
||||
if (!TOKEN_RE.test(token)) {
|
||||
return {
|
||||
error:
|
||||
"CLAUDE_USAGE_INGEST_TOKEN contains an invalid character (a trailing newline or space in the env file is the usual cause)",
|
||||
};
|
||||
}
|
||||
let target;
|
||||
try {
|
||||
target = new URL(`${apiUrl.replace(/\/+$/, "")}/claude-usage`);
|
||||
} catch {
|
||||
return { error: "CLAUDE_USAGE_API_URL is not a valid URL" };
|
||||
}
|
||||
// The ingest token travels in this request: https only, no exception.
|
||||
if (target.protocol !== "https:") return { error: "CLAUDE_USAGE_API_URL must be https://" };
|
||||
return { config: { target: target.href, token } };
|
||||
}
|
||||
|
||||
function buildSnapshot({ now, aggregated, limits }) {
|
||||
const snapshot = {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
generatedAt: new Date(now).toISOString(),
|
||||
};
|
||||
// Left out rather than null: the contract types it as a string, and the
|
||||
// server drops a null there anyway.
|
||||
if (aggregated.claudeCodeVersion) snapshot.claudeCodeVersion = aggregated.claudeCodeVersion;
|
||||
snapshot.timeZone = TIME_ZONE;
|
||||
snapshot.limits = limits;
|
||||
snapshot.days = aggregated.days;
|
||||
snapshot.projects = aggregated.projects;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function pushSnapshot({ target, token, body, fetchImpl }) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetchImpl(target, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
return { error: `push failed: request error (code=${safeCode(err)})` };
|
||||
}
|
||||
// The response body is never read, never logged.
|
||||
try {
|
||||
if (res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {});
|
||||
} catch {
|
||||
// nothing to release
|
||||
}
|
||||
const status = Number.isInteger(res.status) ? res.status : 0;
|
||||
if (status >= 200 && status <= 299) return { status };
|
||||
return { error: `push failed: HTTP ${status}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} argv
|
||||
* @param {object} env
|
||||
* @param {object} deps injectable for tests: home, fetch, now, readFile, openFile, log, logError
|
||||
* @returns {Promise<number>} exit code
|
||||
*/
|
||||
async function main(argv = process.argv.slice(2), env = process.env, deps = {}) {
|
||||
const home = deps.home || os.homedir();
|
||||
const fetchImpl = deps.fetch || globalThis.fetch;
|
||||
const now = deps.now !== undefined ? deps.now : Date.now();
|
||||
const log = deps.log || ((line) => console.log(line));
|
||||
const logError = deps.logError || ((line) => console.error(line));
|
||||
|
||||
const dryRun = argv.includes("--dry-run");
|
||||
|
||||
// Configuration first: a full scan costs seconds, no point spending them to
|
||||
// then discover a typo in the env file. A dry run needs no configuration.
|
||||
let config = null;
|
||||
if (!dryRun) {
|
||||
const result = readConfig(env);
|
||||
if (result.error) {
|
||||
logError(`${PREFIX} configuration error: ${result.error}`);
|
||||
return EXIT_CONFIG;
|
||||
}
|
||||
config = result.config;
|
||||
}
|
||||
|
||||
const stateFile = stateFilePath(deps.stateDir || defaultStateDir(home));
|
||||
const state = loadState(stateFile);
|
||||
const today = torontoDay(now);
|
||||
|
||||
const aggregated = await aggregate({
|
||||
root: deps.transcriptsRoot || path.join(home, ".claude", "projects"),
|
||||
frozenDays: state.frozenDays,
|
||||
today,
|
||||
homedir: home,
|
||||
...(deps.openFile ? { openFile: deps.openFile } : {}),
|
||||
});
|
||||
|
||||
const limits = await fetchLimits({
|
||||
env,
|
||||
credentialsPath: deps.credentialsPath || defaultCredentialsPath(home),
|
||||
...(deps.readFile ? { readFile: deps.readFile } : {}),
|
||||
fetch: fetchImpl,
|
||||
claudeCodeVersion: aggregated.claudeCodeVersion,
|
||||
now,
|
||||
});
|
||||
|
||||
const updated = nextState(state, { openDays: aggregated.openDays, today, limits });
|
||||
// Cache first. A dry run is read-only: it shows what would be sent and
|
||||
// leaves the cache exactly as the cron left it.
|
||||
if (!dryRun) {
|
||||
try {
|
||||
saveState(stateFile, updated);
|
||||
} catch (err) {
|
||||
// Not fatal: the next beat rescans, which is slow but correct.
|
||||
logError(`${PREFIX} warning: state not saved (code=${safeCode(err)})`);
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = buildSnapshot({ now, aggregated, limits: limitsForSnapshot(limits, updated) });
|
||||
const body = JSON.stringify(snapshot);
|
||||
const bytes = Buffer.byteLength(body, "utf-8");
|
||||
|
||||
if (dryRun) log(JSON.stringify(snapshot, null, 2));
|
||||
|
||||
if (bytes > MAX_BODY_BYTES) {
|
||||
logError(`${PREFIX} payload_too_large bytes=${bytes} max=${MAX_BODY_BYTES}`);
|
||||
return EXIT_PUSH;
|
||||
}
|
||||
if (dryRun) return EXIT_OK;
|
||||
|
||||
const pushed = await pushSnapshot({ target: config.target, token: config.token, body, fetchImpl });
|
||||
const summary = `limits=${snapshot.limits.status}${snapshot.limits.detail ? ` detail=${snapshot.limits.detail}` : ""} bytes=${bytes}`;
|
||||
if (pushed.error) {
|
||||
logError(`${PREFIX} ${pushed.error} ${summary}`);
|
||||
return EXIT_PUSH;
|
||||
}
|
||||
log(`${PREFIX} ok status=${pushed.status} ${summary}`);
|
||||
return EXIT_OK;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
// Nothing should land here. The code only, never the object or its
|
||||
// message: either could carry a request option.
|
||||
console.error(`${PREFIX} unexpected failure (code=${safeCode(err)})`);
|
||||
process.exitCode = EXIT_PUSH;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
main,
|
||||
readConfig,
|
||||
buildSnapshot,
|
||||
pushSnapshot,
|
||||
MAX_BODY_BYTES,
|
||||
PUSH_TIMEOUT_MS,
|
||||
EXIT_OK,
|
||||
EXIT_CONFIG,
|
||||
EXIT_PUSH,
|
||||
};
|
||||
85
agent/claude-usage/run-claude-usage.sh
Executable file
85
agent/claude-usage/run-claude-usage.sh
Executable file
|
|
@ -0,0 +1,85 @@
|
|||
#!/bin/sh
|
||||
#
|
||||
# Cron wrapper for push-claude-usage.js: take the lock, load the secrets, check
|
||||
# the install, hand over to node. Every fifteen minutes, unattended.
|
||||
#
|
||||
# NEVER add `set -x`. This script is meant to be piped into `logger`, and the
|
||||
# shell would echo the `. "$ENV_FILE"` expansion — the ingest token — straight
|
||||
# into /var/log/syslog and journald, where it would stay. Same reason nothing
|
||||
# below ever echoes a variable that holds a secret: the checks print the NAME of
|
||||
# what is missing, never its value.
|
||||
#
|
||||
# Suggested crontab line (see agent/README.md):
|
||||
# */15 * * * * $HOME/.local/share/maximus-claude-usage/run-claude-usage.sh 2>&1 | logger -t claude-usage
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
AGENT="$SCRIPT_DIR/push-claude-usage.js"
|
||||
|
||||
# Overridable for testing only; cron uses the default.
|
||||
ENV_FILE="${CLAUDE_USAGE_ENV_FILE:-$HOME/.config/maximus-claude-usage.env}"
|
||||
STATE_DIR="$HOME/.local/state/maximus-claude-usage"
|
||||
LOCK_FILE="$STATE_DIR/run.lock"
|
||||
|
||||
if ! command -v flock >/dev/null 2>&1; then
|
||||
echo "claude-usage: flock not found (util-linux)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
chmod 700 "$STATE_DIR"
|
||||
|
||||
# One run at a time. The first full scan of ~210 MB of transcripts can outlast
|
||||
# the 15-minute beat; a second run would only redo the same work and race the
|
||||
# state file. fd 9 stays open through `exec`, so node holds the lock until it
|
||||
# exits. A beat that finds the lock taken is not an error: exit 0.
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "claude-usage: a run is already in progress, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "claude-usage: env file not found: $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A warning, not a refusal: a wrong mode bit is worth shouting about, but it is
|
||||
# not worth silencing the whole agent over.
|
||||
FILE_MODE=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || echo "")
|
||||
if [ -n "$FILE_MODE" ] && [ "$FILE_MODE" != "600" ]; then
|
||||
echo "claude-usage: warning: $ENV_FILE is mode $FILE_MODE, expected 600" >&2
|
||||
fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. "$ENV_FILE"
|
||||
|
||||
if [ -z "${CLAUDE_USAGE_API_URL:-}" ]; then
|
||||
echo "claude-usage: CLAUDE_USAGE_API_URL is not set in $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${CLAUDE_USAGE_INGEST_TOKEN:-}" ]; then
|
||||
echo "claude-usage: CLAUDE_USAGE_INGEST_TOKEN is not set in $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$AGENT" ]; then
|
||||
echo "claude-usage: agent not found: $AGENT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# cron's PATH is famously short (/usr/bin:/bin), and a node installed through
|
||||
# nvm or /usr/local lives outside it. Set NODE_BIN in the env file when
|
||||
# `command -v node` comes up empty under cron but works in a login shell.
|
||||
NODE_BIN="${NODE_BIN:-node}"
|
||||
if ! command -v "$NODE_BIN" >/dev/null 2>&1; then
|
||||
echo "claude-usage: node not found (NODE_BIN=$NODE_BIN); set NODE_BIN in $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# CLAUDE_USAGE_LIMITS is optional (`off` skips the Anthropic call). Exported
|
||||
# too, otherwise setting it in the env file silently does nothing.
|
||||
export CLAUDE_USAGE_API_URL CLAUDE_USAGE_INGEST_TOKEN CLAUDE_USAGE_LIMITS
|
||||
|
||||
exec "$NODE_BIN" "$AGENT" "$@"
|
||||
163
agent/claude-usage/state.js
Normal file
163
agent/claude-usage/state.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// Local cache of the claude-usage agent (issue #50):
|
||||
// ~/.local/state/maximus-claude-usage/state.json.
|
||||
//
|
||||
// Two things live here, both rebuilt from scratch when the file is lost:
|
||||
//
|
||||
// * frozenDays — every Toronto day strictly before yesterday, empty days
|
||||
// included, as { models, projects } (the shape of an aggregate() open
|
||||
// day). A frozen day is never recomputed, and aggregate() skips every
|
||||
// transcript last written before the day after the newest frozen day: this
|
||||
// is what keeps a beat from re-reading the whole 30-day window. An empty
|
||||
// day left unfrozen would hold that threshold back forever.
|
||||
// Yesterday stays open: a session running past midnight still writes
|
||||
// yesterday's lines into a transcript touched today.
|
||||
// * lastGoodLimits — the last `ok` limits with their fetchedAt, served when
|
||||
// the current call fails, so the dashboard shows the last known values and
|
||||
// how old they are instead of nothing.
|
||||
//
|
||||
// The file holds no secret. It is still written mode 600, atomically (temp file
|
||||
// suffixed with the pid in the same directory, then rename), so a concurrent
|
||||
// reader sees the old state or the new one, never a truncated JSON.
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { shiftDay } = require("./aggregate.js");
|
||||
|
||||
const STATE_VERSION = 1;
|
||||
const RETENTION_DAYS = 35;
|
||||
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function defaultStateDir(home = os.homedir()) {
|
||||
return path.join(home, ".local", "state", "maximus-claude-usage");
|
||||
}
|
||||
|
||||
function stateFilePath(stateDir) {
|
||||
return path.join(stateDir, "state.json");
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isDay(value) {
|
||||
return typeof value === "string" && DAY_RE.test(value) && shiftDay(value, 0) === value;
|
||||
}
|
||||
|
||||
function emptyState() {
|
||||
return { version: STATE_VERSION, frozenDays: {}, lastGoodLimits: null };
|
||||
}
|
||||
|
||||
// Any problem — missing file, unreadable, truncated JSON, wrong shape — gives
|
||||
// the empty state: the beat then does a full scan and rebuilds the cache.
|
||||
// Entries are kept only in their expected shape; their counters are re-read
|
||||
// defensively by aggregate() anyway.
|
||||
function loadState(file, { readFile = (f) => fs.readFileSync(f, "utf-8") } = {}) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(String(readFile(file)));
|
||||
} catch {
|
||||
return emptyState();
|
||||
}
|
||||
if (!isPlainObject(parsed)) return emptyState();
|
||||
|
||||
const state = emptyState();
|
||||
if (isPlainObject(parsed.frozenDays)) {
|
||||
for (const [day, value] of Object.entries(parsed.frozenDays)) {
|
||||
if (!isDay(day) || !isPlainObject(value)) continue;
|
||||
state.frozenDays[day] = {
|
||||
models: isPlainObject(value.models) ? value.models : {},
|
||||
projects: isPlainObject(value.projects) ? value.projects : {},
|
||||
};
|
||||
}
|
||||
}
|
||||
const last = parsed.lastGoodLimits;
|
||||
if (
|
||||
isPlainObject(last) &&
|
||||
typeof last.fetchedAt === "string" &&
|
||||
!Number.isNaN(Date.parse(last.fetchedAt)) &&
|
||||
Array.isArray(last.items)
|
||||
) {
|
||||
state.lastGoodLimits = { fetchedAt: last.fetchedAt, items: last.items.filter(isPlainObject) };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next state after one beat. Pure.
|
||||
*
|
||||
* @param {object} state current state (loadState)
|
||||
* @param {object} opts
|
||||
* @param {object} opts.openDays aggregate().openDays
|
||||
* @param {string} opts.today Toronto "YYYY-MM-DD"
|
||||
* @param {object} opts.limits fetchLimits() result
|
||||
*/
|
||||
function nextState(state, { openDays = {}, today, limits = null }) {
|
||||
const yesterday = shiftDay(today, -1);
|
||||
const oldest = shiftDay(today, -RETENTION_DAYS);
|
||||
|
||||
const frozenDays = {};
|
||||
for (const [day, value] of Object.entries(state.frozenDays || {})) {
|
||||
if (day >= oldest) frozenDays[day] = value;
|
||||
}
|
||||
for (const [day, value] of Object.entries(openDays || {})) {
|
||||
if (!isDay(day) || day >= yesterday || day < oldest) continue;
|
||||
frozenDays[day] = { models: value.models || {}, projects: value.projects || {} };
|
||||
}
|
||||
|
||||
const lastGoodLimits =
|
||||
limits && limits.status === "ok"
|
||||
? { fetchedAt: limits.fetchedAt, items: limits.items }
|
||||
: state.lastGoodLimits || null;
|
||||
|
||||
return { version: STATE_VERSION, frozenDays, lastGoodLimits };
|
||||
}
|
||||
|
||||
// The `limits` block of the snapshot: the current result when it is `ok`,
|
||||
// otherwise its status and detail with the last good items and their date.
|
||||
function limitsForSnapshot(limits, state) {
|
||||
if (limits.status === "ok") {
|
||||
return { status: "ok", fetchedAt: limits.fetchedAt, items: limits.items };
|
||||
}
|
||||
const out = { status: limits.status };
|
||||
if (limits.detail) out.detail = limits.detail;
|
||||
const last = state && state.lastGoodLimits;
|
||||
out.fetchedAt = last ? last.fetchedAt : null;
|
||||
out.items = last ? last.items : [];
|
||||
return out;
|
||||
}
|
||||
|
||||
// Atomic write: temp file in the same directory (rename is only atomic within
|
||||
// one file system), mode 600, then rename over the old file.
|
||||
function saveState(file, state) {
|
||||
const dir = path.dirname(file);
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
||||
// writeFileSync only applies `mode` when it creates the file: a temp file
|
||||
// left behind by a killed run would keep its old bits otherwise.
|
||||
fs.chmodSync(tmp, 0o600);
|
||||
fs.renameSync(tmp, file);
|
||||
} catch (err) {
|
||||
try {
|
||||
fs.unlinkSync(tmp);
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadState,
|
||||
saveState,
|
||||
nextState,
|
||||
limitsForSnapshot,
|
||||
emptyState,
|
||||
defaultStateDir,
|
||||
stateFilePath,
|
||||
RETENTION_DAYS,
|
||||
STATE_VERSION,
|
||||
};
|
||||
Loading…
Reference in a new issue