Compare commits

..

4 commits

Author SHA1 Message Date
le king fu
719d65129a docs(claude-md): count the threshold and prototype cases of the rebased #49 fix
All checks were successful
CI / verification (pull_request) Successful in 27s
CI / verification (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:26:20 -04:00
le king fu
7f01e6afe5 docs(agent): document the claude-usage agent install and its guarantees
Resolves #50

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:26:15 -04:00
le king fu
e274ca7ed5 feat(agent): add the claude-usage agent: plan limits, local cache, push
limits.js reads the Claude Code OAuth credentials read-only and calls the
pinned Anthropic usage endpoint once (redirect refused, 10 s timeout, no
retry, never a refresh); detail codes come from a fixed enum only.
state.js freezes every Toronto day before yesterday, empty ones included,
prunes past 35 days, keeps the last good limits, and writes atomically in
mode 600. push-claude-usage.js writes the cache before pushing, refuses a
non-https URL (exit 1) and a body over 64 KiB (exit 2, nothing sent), and
prints no token. run-claude-usage.sh takes a non-blocking flock so a beat
that overlaps the previous one exits 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:26:15 -04:00
le king fu
c888f7c633 fix(agent): take the scan threshold from the open days, key maps without prototype
Some checks are pending
CI / verification (pull_request) Successful in 28s
CI / verification (push) Waiting to run
The threshold came from the most recent frozen key: a frozen day in the
future, or a gap in the frozen run, pushed it past open days whose files
were then never read, and the agent would have frozen them empty for good.
It is now the oldest open day of the window (the same day as before for the
agent's contiguous frozen prefix).

Project, model and day maps are now Object.create(null): a project named
__proto__ or constructor wrote NaN counters onto Object.prototype and
dropped out of the top.

Resolves #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:25:43 -04:00
3 changed files with 157 additions and 29 deletions

View file

@ -68,7 +68,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
## Tests ## 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. - **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) — 606 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__/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__/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) - `__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,7 +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__/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__/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.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 (106 cas). Agregation des transcripts, #49 (38 cas) : jours Toronto et changement d'heure, regles de projet, deduplication des requetes, 30 jours, top 8 + `(autres)`, 12 modeles, seuil de scan des jours figes. 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__/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__/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) - `__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 - Runtime reste 0-dep ; vitest en devDep uniquement

View file

@ -431,16 +431,20 @@ describe("at most 12 models per day", () => {
// --- Frozen days and the scan threshold ---------------------------------------- // --- Frozen days and the scan threshold ----------------------------------------
describe("frozen days", () => { describe("frozen days", () => {
const FROZEN = { // What the agent hands over: a contiguous frozen run from the window start
"2026-10-01": { // through 2026-10-01, empty days included.
models: { "claude-opus-5-5": { input: 7, output: 7, cacheCreation: 7, cacheRead: 7, requests: 7 } }, const FROZEN = {};
projects: { alpha: { work: 21, cacheRead: 7, requests: 7 } }, for (const day of windowDays("2026-10-04")) {
}, if (day > "2026-10-01") break;
"2026-09-30": { models: {}, projects: {} }, FROZEN[day] = { models: {}, projects: {} };
}
FROZEN["2026-10-01"] = {
models: { "claude-opus-5-5": { input: 7, output: 7, cacheCreation: 7, cacheRead: 7, requests: 7 } },
projects: { alpha: { work: 21, cacheRead: 7, requests: 7 } },
}; };
it("opens no file whose mtime day is before the threshold (spy on file opening)", async () => { it("opens no file whose mtime day is before the threshold (spy on file opening)", async () => {
// Threshold = day after the most recent frozen day = 2026-10-02. // Threshold = oldest open day = day after the frozen run = 2026-10-02.
const old = writeTranscript( const old = writeTranscript(
"a/old.jsonl", "a/old.jsonl",
[line({ ts: "2026-10-02T15:00:00Z", output: 5 })], // would count if opened [line({ ts: "2026-10-02T15:00:00Z", output: 5 })], // would count if opened
@ -544,10 +548,78 @@ describe("frozen days", () => {
expect(dayOf(r, "2026-09-30").models).toEqual({}); expect(dayOf(r, "2026-09-30").models).toEqual({});
expect(r.openDays["2026-10-01"]).toBeUndefined(); expect(r.openDays["2026-10-01"]).toBeUndefined();
expect(r.openDays["2026-09-30"]).toBeUndefined(); expect(r.openDays["2026-09-30"]).toBeUndefined();
expect(Object.keys(r.openDays)).toHaveLength(28); expect(Object.keys(r.openDays)).toEqual(["2026-10-02", "2026-10-03", "2026-10-04"]);
expect(r.projects).toEqual([{ name: "alpha", work: 21 + 1 + 3, cacheRead: 7, requests: 8 }]); expect(r.projects).toEqual([{ name: "alpha", work: 21 + 1 + 3, cacheRead: 7, requests: 8 }]);
}); });
it("a frozen day in the future never hides an open day's files", async () => {
const file = writeTranscript(
"a/open.jsonl",
[line({ ts: "2026-10-03T15:00:00Z", output: 3 })],
"2026-10-03T16:00:00Z",
);
const opened = [];
const r = await aggregate({
root: tmpDir,
today: "2026-10-04",
homedir: HOME,
frozenDays: { "2026-11-15": { models: {}, projects: {} } },
openFile: (f, o) => {
opened.push(f);
return fs.createReadStream(f, o);
},
});
expect(opened).toEqual([file]);
expect(r.openDays["2026-10-03"].models["claude-opus-5-5"].output).toBe(3);
});
it("a gap in the frozen run keeps the open day before the gap scanned", async () => {
// 10-03 open, 10-04 (today) frozen: the oldest open day sets the threshold.
const file = writeTranscript(
"a/open.jsonl",
[line({ ts: "2026-10-03T15:00:00Z", output: 3 })],
"2026-10-03T16:00:00Z",
);
const frozenDays = {};
for (const day of windowDays("2026-10-04")) {
if (day !== "2026-10-03") frozenDays[day] = { models: {}, projects: {} };
}
const opened = [];
const r = await aggregate({
root: tmpDir,
today: "2026-10-04",
homedir: HOME,
frozenDays,
openFile: (f, o) => {
opened.push(f);
return fs.createReadStream(f, o);
},
});
expect(opened).toEqual([file]);
expect(Object.keys(r.openDays)).toEqual(["2026-10-03"]);
expect(r.openDays["2026-10-03"].models["claude-opus-5-5"].output).toBe(3);
});
it("every day frozen: no file is opened", async () => {
writeTranscript("a/x.jsonl", [line({ ts: "2026-10-04T15:00:00Z" })], "2026-10-04T16:00:00Z");
const frozenDays = {};
for (const day of windowDays("2026-10-04")) frozenDays[day] = { models: {}, projects: {} };
const opened = [];
const r = await aggregate({
root: tmpDir,
today: "2026-10-04",
homedir: HOME,
frozenDays,
openFile: (f, o) => {
opened.push(f);
return fs.createReadStream(f, o);
},
});
expect(opened).toEqual([]);
expect(Object.keys(r.openDays)).toEqual([]);
expect(r.days).toHaveLength(WINDOW_DAYS);
});
it("re-reads frozen counters defensively (bad numbers -> 0, junk ignored)", async () => { it("re-reads frozen counters defensively (bad numbers -> 0, junk ignored)", async () => {
const r = await aggregate({ const r = await aggregate({
root: tmpDir, root: tmpDir,
@ -635,6 +707,53 @@ describe("robustness", () => {
}); });
}); });
// --- Names that collide with Object.prototype ------------------------------------
describe("prototype-shaped names", () => {
const PROTO_KEYS = ["work", "cacheRead", "requests", "input", "output", "cacheCreation"];
function prototypeIsClean() {
for (const k of PROTO_KEYS) {
expect(Object.prototype.hasOwnProperty.call(Object.prototype, k)).toBe(false);
expect(Object.prototype.hasOwnProperty.call(Object, k)).toBe(false);
}
}
it("projects named __proto__ and constructor are counted, Object.prototype untouched", async () => {
writeTranscript(
"a/x.jsonl",
[
line({ ts: "2026-10-03T15:00:00Z", cwd: `${HOME}/claude-code/__proto__`, output: 5 }),
line({ ts: "2026-10-03T16:00:00Z", cwd: `${HOME}/claude-code/constructor`, output: 7 }),
line({ ts: "2026-10-03T17:00:00Z", model: "constructor", output: 2 }),
],
"2026-10-04T12:00:00Z",
);
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME });
prototypeIsClean();
const byName = Object.fromEntries(r.projects.map((p) => [p.name, p]));
expect(Object.keys(byName).sort()).toEqual(["__proto__", "alpha", "constructor"].sort());
expect(Object.getOwnPropertyDescriptor(byName, "__proto__").value.work).toBe(1 + 5);
expect(byName.constructor.work).toBe(1 + 7);
expect(dayOf(r, "2026-10-03").models.constructor.output).toBe(2);
expect(JSON.parse(JSON.stringify(r.days)).length).toBe(WINDOW_DAYS);
});
it("a __proto__ key in a frozen day stays a plain project", async () => {
const frozenDays = JSON.parse(
'{"2026-10-01":{"models":{},"projects":{"__proto__":{"work":4,"cacheRead":0,"requests":1},"constructor":{"work":6,"cacheRead":0,"requests":1}}}}',
);
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays });
prototypeIsClean();
expect(r.projects.map((p) => [p.name, p.work]).sort()).toEqual(
[
["__proto__", 4],
["constructor", 6],
].sort(),
);
});
});
// ============================================================================ // ============================================================================
// The agent around the aggregation (issue #50): plan limits, local cache, // The agent around the aggregation (issue #50): plan limits, local cache,
// snapshot assembly and delivery, cron wrapper. // snapshot assembly and delivery, cron wrapper.

View file

@ -18,9 +18,13 @@
// are deduplicated on (message.id, requestId) across ALL files, keeping the // are deduplicated on (message.id, requestId) across ALL files, keeping the
// line with the largest output_tokens. // line with the largest output_tokens.
// * THE SCAN THRESHOLD. Frozen days are never recomputed. A file whose mtime // * 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 // (Toronto day) is before the oldest open day of the window cannot hold a
// hold a line on an open day, so it is not even opened: this is what keeps // line on an open day, so it is not even opened: this is what keeps a run
// a run from re-reading ~210 MB of transcripts every time. // 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 fs = require("node:fs");
const os = require("node:os"); const os = require("node:os");
@ -136,6 +140,13 @@ function count(value) {
return Number.isSafeInteger(value) && value >= 0 ? value : 0; 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() { function emptyModel() {
return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, requests: 0 }; 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) (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]; for (const name of names.slice(0, MAX_MODELS_PER_DAY - 1)) out[name] = models[name];
const other = emptyModel(); const other = emptyModel();
if (hasOther) addModel(other, models[OTHER_MODEL]); 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 // Frozen days come from the agent's state file: re-read through the same
// counters, never trusted verbatim. // counters, never trusted verbatim.
function normalizeDay(day) { function normalizeDay(day) {
const models = {}; const models = dict();
const projects = {}; const projects = dict();
const srcModels = day && typeof day.models === "object" && day.models ? day.models : {}; const srcModels = day && typeof day.models === "object" && day.models ? day.models : {};
const srcProjects = const srcProjects =
day && typeof day.projects === "object" && day.projects ? day.projects : {}; 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"); 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") { if (frozenDays && typeof frozenDays === "object") {
for (const [day, value] of Object.entries(frozenDays)) { for (const [day, value] of Object.entries(frozenDays)) {
if (isDay(day)) frozen[day] = value; if (isDay(day)) frozen[day] = value;
@ -305,15 +316,13 @@ async function aggregate({
const windowStart = window[0]; const windowStart = window[0];
const openDayList = window.filter((d) => !Object.prototype.hasOwnProperty.call(frozen, d)); const openDayList = window.filter((d) => !Object.prototype.hasOwnProperty.call(frozen, d));
// Scan threshold: the day after the most recent frozen day. Floored at the // Scan threshold: the oldest open day of the window. With the agent's
// window start — a file last written before the window cannot hold a line // contiguous frozen prefix this is the day after the most recent frozen day;
// inside it. // computed from the open days rather than from the frozen keys, so a frozen
const frozenList = Object.keys(frozen).sort(); // day in the future or a gap in the frozen run can never push the threshold
let threshold = windowStart; // past an open day and silently empty it. A file last written before the
if (frozenList.length) { // window cannot hold a line inside it. Every day frozen: nothing to read.
const afterFrozen = shiftDay(frozenList[frozenList.length - 1], 1); const threshold = openDayList.length ? openDayList[0] : shiftDay(today, 1);
if (afterFrozen > threshold) threshold = afterFrozen;
}
// (message.id, requestId) -> best line so far. // (message.id, requestId) -> best line so far.
const requests = new Map(); const requests = new Map();
@ -383,8 +392,8 @@ async function aggregate({
// Open days: every non-frozen day of the window, empty ones included, so // 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 // the caller can freeze them all (an unfrozen empty day would hold the
// threshold back and every run would re-read the whole window). // threshold back and every run would re-read the whole window).
const openDays = {}; const openDays = dict();
for (const day of openDayList) openDays[day] = { models: {}, projects: {} }; for (const day of openDayList) openDays[day] = { models: dict(), projects: dict() };
for (const r of requests.values()) { for (const r of requests.values()) {
const bucket = openDays[r.day]; const bucket = openDays[r.day];
if (!bucket.models[r.model]) bucket.models[r.model] = emptyModel(); 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. // Merged 30-day series and project totals over the window.
const days = []; const days = [];
const projectTotals = {}; const projectTotals = dict();
for (const date of window) { for (const date of window) {
const source = Object.prototype.hasOwnProperty.call(frozen, date) const source = Object.prototype.hasOwnProperty.call(frozen, date)
? normalizeDay(frozen[date]) ? normalizeDay(frozen[date])